Skip to content

Citation Coverage Matrix

This page is generated by scripts/generate_citation_matrix.py from the live @cites(...) decorators in src/rwa_calc/. Each heading is one regulatory article; expand a function below it to read the implementation inline. See citation-tracking.md for the conventions and canonical citation grammar.

Regenerate after annotation changes:

uv run python scripts/generate_citation_matrix.py

Last generated: 2026-06-21.

CRR (Capital Requirements Regulation)

CRR Art. 111 — Exposure value

build_product_to_risk_type_expr — src/rwa_calc/engine/ccf.py:120
@cites("CRR Art. 111")
def build_product_to_risk_type_expr(
    product_col: str = "obs_product",
) -> pl.Expr:
    """Build a Polars expression mapping a concrete OBS product to its risk_type.

    Resolves the abstract Annex I ``risk_type`` bucket (FR / MLR / ...) from a
    normalised concrete product key via the ``obs_product_to_risk_type`` rulepack
    CategoryMap (rebound to ``_ANNEX1_PRODUCT_RISK_TYPE`` at module load). The
    mapping is framework-invariant (CRR Annex I == PRA PS1/26 Table A1 for every
    product in scope). Unknown / unmapped products and nulls produce a null
    result, so the caller can leave the existing ``risk_type`` resolution
    untouched.

    Args:
        product_col: Name of the obs_product column on the frame.

    Returns:
        String Polars expression evaluating to the resolved risk_type (or null
        when the product is null / unmapped).
    """
    casted = pl.col(product_col).cast(pl.Utf8, strict=False).fill_null("")
    lowered = casted.str.to_lowercase()
    canonical = lowered.replace_strict(OBS_PRODUCT_SYNONYMS, default=casted.str.to_uppercase())
    return canonical.replace_strict(
        _ANNEX1_PRODUCT_RISK_TYPE,
        default=pl.lit(None, dtype=pl.Utf8),
    )
sa_ccf_expression — src/rwa_calc/engine/ccf.py:176
@cites("CRR Art. 111")
def sa_ccf_expression(
    risk_type_col: str = "risk_type",
    is_basel_3_1: bool = False,
) -> pl.Expr:
    """Polars expression mapping risk_type to SA CCFs.

    CRR Art. 111 (Annex I categories) when ``is_basel_3_1`` is False, PRA
    PS1/26 Table A1 when True. The CCF values come from the rulepack
    (``sa_ccf`` lookup); unrecognised risk_type falls back to the
    MR-equivalent ``sa_ccf_default`` (50%).
    """
    table = _SA_CCF_B31_MAP if is_basel_3_1 else _SA_CCF_CRR_MAP
    canonical = _normalize_risk_type(risk_type_col)
    return (
        pl.when(canonical == "FR")
        .then(pl.lit(table["FR"]))
        .when(canonical == "FRC")
        .then(pl.lit(table["FRC"]))
        .when(canonical == "MR")
        .then(pl.lit(table["MR"]))
        # CRR Annex I Row 3 issued medium-risk OBS items — explicit 50%
        # (mirrors MR / Row 4) so EAD is provably equal, not a default fallback.
        .when(canonical == "MR_ISSUED")
        .then(pl.lit(table["MR_ISSUED"]))
        .when(canonical == "OC")
        .then(pl.lit(table["OC"]))
        .when(canonical == "MLR")
        .then(pl.lit(table["MLR"]))
        .when(canonical == "LR")
        .then(pl.lit(table["LR"]))
        .otherwise(pl.lit(_SA_CCF_DEFAULT))
    )
apply_ccf — src/rwa_calc/engine/ccf.py:286
    @cites("CRR Art. 111")
    @cites("CRR Art. 166")
    def apply_ccf(
        self,
        exposures: pl.LazyFrame,
        config: CalculationConfig,
        *,
        pack: ResolvedRulepack | None = None,
    ) -> pl.LazyFrame:
        """
        Apply CCF to calculate EAD for off-balance sheet exposures.

        CCF determination follows CRR Art. 111 categories based on risk_type:
        - SA: FR=100%, MR=50%, MLR=20%, LR=0%
        - F-IRB Art. 166(8)(d): MR/MLR/OC commitments (credit lines / NIFs / RUFs)
          when ``is_obs_commitment=True`` -> 75%
        - F-IRB Art. 166(10) fallback: issued OBS items (``is_obs_commitment=False``)
          -> 100% FR / 50% MR / 20% MLR / 0% LR
        - F-IRB Art. 166(8)(b): MLR with ``is_short_term_trade_lc=True`` -> 20%
        - A-IRB CRR: Uses ccf_modelled if provided, otherwise falls back to SA
        - A-IRB B31: Own CCF only for revolving (non-100% SA); else SA CCF (Art. 166D)
        - Art. 111(1)(c): When underlying_risk_type is specified, CCF is capped
          at the lower of the commitment's CCF and the underlying OBS item's CCF

        Args:
            exposures: Exposures with nominal_amount, risk_type, and approach columns
            config: Calculation configuration

        Returns:
            LazyFrame with ead_from_ccf and ccf columns added
        """
        schema = exposures.collect_schema()
        names = schema.names()
        original_has_risk_type = "risk_type" in names
        original_has_underlying = "underlying_risk_type" in names
        original_has_interest = "interest" in names
        has_provision_cols = "nominal_after_provision" in names and "provision_on_drawn" in names

        exposures, added_cols = self._ensure_columns(exposures, names, has_provision_cols)
        exposures = self._compute_ccf(exposures, config, pack=pack)
        exposures = self._compute_ead(exposures, has_provision_cols, config, pack=pack)
        exposures = self._build_audit_trail(
            exposures, original_has_risk_type, original_has_underlying, original_has_interest
        )

        # Clean up temp and default-populated columns
        return exposures.drop(
            "_sa_ccf_from_risk_type",
            "_firb_ccf_from_risk_type",
            "_nominal_is_zero",
            *added_cols,
        )
_compute_ccf — src/rwa_calc/engine/ccf.py:418
    @cites("CRR Art. 111")
    @cites("PS1/26, paragraph 111")
    def _compute_ccf(
        self,
        exposures: pl.LazyFrame,
        config: CalculationConfig,
        *,
        pack: ResolvedRulepack | None = None,
    ) -> pl.LazyFrame:
        """Compute CCF based on risk type and approach.

        Determines SA and F-IRB CCFs from risk_type, then selects the final CCF
        based on the exposure's approach (SA/F-IRB/A-IRB).

        CRR Annex I / Art. 111(1) obs_product fill: before resolving CCFs, any row
        whose ``risk_type`` is null/empty has its ``risk_type`` resolved from the
        concrete ``obs_product`` key via ANNEX1_PRODUCT_RISK_TYPE (framework-
        invariant). An explicit ``risk_type`` always wins — the fill is gated on
        the existing value being null/empty.

        Applies the PRA PS1/26 Art. 111(1) Table A1 Row 4(b) override: a UK
        residential-property commitment (``is_uk_residential_mortgage_commitment``)
        gets a 50% SA CCF under Basel 3.1, except where the otherwise-resolved
        CCF is 10% (Row 6 UCC) or 100% (Row 2) — the Row 4(b) carve-out.
        """
        # CRR Annex I / Art. 111(1): resolve risk_type from the concrete OBS
        # product when (and only when) no explicit risk_type was supplied. Explicit
        # risk_type always wins; an unmapped/null product yields null and leaves
        # risk_type unchanged.
        risk_type_is_blank = (
            pl.col("risk_type").cast(pl.Utf8, strict=False).fill_null("").str.len_chars() == 0
        )
        product_risk_type = build_product_to_risk_type_expr("obs_product")
        exposures = exposures.with_columns(
            pl.when(risk_type_is_blank & product_risk_type.is_not_null())
            .then(product_risk_type)
            .otherwise(pl.col("risk_type"))
            .alias("risk_type"),
        )

        resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
        # S9c: the F-IRB-uses-SA-CCF routing gate (Art. 166C) reads the cited pack
        # Feature; sa_ccf_expression / _firb_ccf_for_col keep their is_basel_3_1 bool
        # plumbing params (Option B). All CCF VALUES stay static data-layer tables.
        is_b31 = resolved_pack.feature("firb_uses_sa_ccf")

        if is_b31:
            # Basel 3.1 Art. 166C: F-IRB uses SA CCFs (PRA PS1/26 Art. 111 Table A1)
            # FR=100%, MR=50%, MLR=20%, LR(UCC)=10%
            firb_ccf = sa_ccf_expression(is_basel_3_1=True)
        else:
            # CRR F-IRB: Art. 166(8)(d) -> 75% for credit lines / NIFs / RUFs
            # (is_obs_commitment=True); Art. 166(10) -> 100/50/20/0% fallback for
            # issued OBS items not in scope of paragraphs 1-8.
            firb_ccf = _firb_ccf_for_col("risk_type")

        exposures = exposures.with_columns(
            sa_ccf_expression(is_basel_3_1=is_b31).alias("_sa_ccf_from_risk_type"),
            firb_ccf.alias("_firb_ccf_from_risk_type"),
            (pl.col("nominal_amount").cast(pl.Float64, strict=False).abs() < 1e-10).alias(
                "_nominal_is_zero"
            ),
        )

        # CRR maturity-dependent OC override: under CRR, "other commitments" mapped
        # to MR (50%, >1yr) or MLR (20%, <=1yr). The sa_ccf_expression gives OC 50%
        # as the conservative default; override to 20% when remaining maturity <= 1yr.
        if not is_b31:
            normalized_rt = pl.col("risk_type").fill_null("").str.to_lowercase()
            is_oc = normalized_rt.is_in(["oc", "other_commit"])
            schema_names = exposures.collect_schema().names()
            if "maturity_date" in schema_names:
                is_short_maturity = pl.col("maturity_date").is_not_null() & (
                    (
                        pl.col("maturity_date").cast(pl.Date) - pl.lit(config.reporting_date)
                    ).dt.total_days()
                    <= _OC_SHORT_MATURITY_THRESHOLD_DAYS
                )
                exposures = exposures.with_columns(
                    pl.when(is_oc & is_short_maturity)
                    .then(pl.lit(_OC_SHORT_MATURITY_CCF))
                    .otherwise(pl.col("_sa_ccf_from_risk_type"))
                    .alias("_sa_ccf_from_risk_type"),
                )

        # PRA PS1/26 Art. 111(1) Table A1 Row 4(b): commitments to extend credit
        # secured by residential property attract a 50% CCF — "to the extent
        # that they are not subject to a conversion factor of 10% or 100%". When
        # the flag is set under Basel 3.1, override the otherwise-resolved SA CCF
        # to the MR / Row 4(b) rate (50%), unless that CCF is already 10% (Row 6
        # UCC) or 100% (Row 2), in which case the carve-out leaves it untouched.
        # No effect under CRR (Table A1 is Basel 3.1 only) — see the gate below.
        if is_b31:
            row_4b_ccf = _SA_CCF_B31_MAP["MR"]
            carve_out_ccfs = (_SA_CCF_B31_MAP["LR"], _SA_CCF_B31_MAP["FR"])
            is_resi_commitment = pl.col("is_uk_residential_mortgage_commitment").fill_null(False)
            sa_not_in_carve_out = ~pl.col("_sa_ccf_from_risk_type").is_in(carve_out_ccfs)
            exposures = exposures.with_columns(
                pl.when(is_resi_commitment & sa_not_in_carve_out)
                .then(pl.lit(row_4b_ccf))
                .otherwise(pl.col("_sa_ccf_from_risk_type"))
                .alias("_sa_ccf_from_risk_type"),
            )

            exposures = self._apply_purchased_receivable_ccf(exposures)

        # Art. 111(1)(c): commitment-to-issue lower-of rule.
        # When underlying_risk_type is specified, cap CCFs at the underlying item's CCF.
        # "the lower of (i) the CCF applicable to the underlying OBS item and
        #  (ii) the CCF applicable to the commitment type"
        has_underlying = pl.col("underlying_risk_type").fill_null("").str.len_chars() > 0
        underlying_sa = sa_ccf_expression("underlying_risk_type", is_basel_3_1=is_b31)
        exposures = exposures.with_columns(
            pl.when(has_underlying)
            .then(pl.min_horizontal(pl.col("_sa_ccf_from_risk_type"), underlying_sa))
            .otherwise(pl.col("_sa_ccf_from_risk_type"))
            .alias("_sa_ccf_from_risk_type"),
            pl.when(has_underlying)
            .then(
                pl.min_horizontal(
                    pl.col("_firb_ccf_from_risk_type"),
                    sa_ccf_expression("underlying_risk_type", is_basel_3_1=True)
                    if is_b31
                    else _firb_ccf_for_col("underlying_risk_type"),
                )
            )
            .otherwise(pl.col("_firb_ccf_from_risk_type"))
            .alias("_firb_ccf_from_risk_type"),
        )

        # A-IRB CCF: use modelled value, with Basel 3.1 restrictions
        ccf_modelled_expr = pl.col("ccf_modelled").cast(pl.Float64, strict=False)
        if is_b31:
            # Basel 3.1 Art. 166D(1)(a): own-estimate CCFs only for revolving
            # facilities whose SA CCF is not 100% (Table A1 Row 2 carve-out).
            # Non-revolving A-IRB must use SA CCFs from Table A1.
            # Revolving with SA CCF < 100%: own CCF with 50% SA floor (CRE32.27).
            airb_revolving_ccf = pl.max_horizontal(
                ccf_modelled_expr.fill_null(pl.col("_sa_ccf_from_risk_type")),
                pl.col("_sa_ccf_from_risk_type")
                * scalar_value(resolved_pack.scalar_param("airb_revolving_ccf_floor_multiplier")),
            )
            is_eligible_for_own_ccf = pl.col("is_revolving").fill_null(False) & (
                pl.col("_sa_ccf_from_risk_type") < 1.0
            )
            airb_ccf = (
                pl.when(is_eligible_for_own_ccf)
                .then(airb_revolving_ccf)
                .otherwise(pl.col("_sa_ccf_from_risk_type"))
            )
        else:
            airb_ccf = ccf_modelled_expr.fill_null(pl.col("_sa_ccf_from_risk_type"))

        # Select final CCF based on approach
        return exposures.with_columns(
            pl.when(pl.col("_nominal_is_zero"))
            .then(pl.lit(0.0))
            .when(pl.col("approach") == ApproachType.AIRB.value)
            .then(airb_ccf)
            .when(pl.col("approach") == ApproachType.FIRB.value)
            .then(pl.col("_firb_ccf_from_risk_type"))
            # CRR Art. 147(8): specialised-lending slotting is a corporate IRB
            # exposure, so its OBS EAD is governed by Art. 166(8) — the F-IRB CCF
            # (e.g. MR -> 75%), not the SA 50%. Under Basel 3.1, Art. 166C makes
            # F-IRB CCFs equal SA CCFs, so slotting stays on the SA path below.
            .when((pl.col("approach") == ApproachType.SLOTTING.value) & (not is_b31))
            .then(pl.col("_firb_ccf_from_risk_type"))
            .otherwise(pl.col("_sa_ccf_from_risk_type"))
            .alias("ccf"),
        )
resolve_provisions — src/rwa_calc/engine/crm/provisions.py:37
@cites("CRR Art. 111")
def resolve_provisions(
    exposures: pl.LazyFrame,
    provisions: pl.LazyFrame,
    config: CalculationConfig,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """
    Resolve provisions with multi-level beneficiary and drawn-first deduction.

    This is called *before* CCF so that nominal_after_provision feeds into
    the CCF calculation: ``ead_from_ccf = nominal_after_provision * ccf``.

    Resolution levels (based on beneficiary_type):
    1. Direct (loan/exposure/contingent): join on exposure_reference
    2. Facility: join on parent_facility_reference, pro-rata by exposure weight
    3. Counterparty: join on counterparty_reference, pro-rata by exposure weight

    SA drawn-first deduction (CRR Art. 111(2)):
    - ``floored_drawn = max(0, drawn_amount)``
    - ``provision_on_drawn = min(provision_allocated, floored_drawn)``
    - ``provision_on_nominal = min(remainder, nominal_amount)``
    - Interest is never reduced by provision.

    IRB/Slotting: provision_on_drawn=0, provision_on_nominal=0 (provisions
    feed into EL shortfall/excess instead). provision_allocated is tracked.

    Args:
        exposures: Exposures with drawn_amount, interest, nominal_amount, approach
        provisions: Provision data with beneficiary_reference, amount,
                    and optionally beneficiary_type
        config: Calculation configuration

    Returns:
        Exposures with provision_allocated, provision_on_drawn,
        provision_on_nominal, provision_deducted, nominal_after_provision
    """
    prov_schema = provisions.collect_schema()
    exp_schema = exposures.collect_schema()
    has_beneficiary_type = "beneficiary_type" in prov_schema.names()
    has_parent_facility = "parent_facility_reference" in exp_schema.names()
    has_risk_type = "risk_type" in exp_schema.names()

    if has_beneficiary_type:
        # S9e: the SA-CCF table used as the pro-rata provision-weighting basis is
        # regime-selected via the cited pack Feature; _resolve_provisions_multi_level
        # (and the sa_ccf_expression it calls) keep their is_basel_3_1 bool param
        # (Option B). The CCF table VALUES stay static data-layer constants.
        resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
        exposures = _resolve_provisions_multi_level(
            exposures,
            provisions,
            has_parent_facility,
            has_risk_type,
            resolved_pack.feature("sa_revised_ccf_table"),
        )
    else:
        # Fallback: direct-only join (backward compat)
        provisions_agg = provisions.group_by("beneficiary_reference").agg(
            pl.col("amount").sum().alias("provision_allocated"),
        )
        exposures = exposures.join(
            provisions_agg,
            left_on="exposure_reference",
            right_on="beneficiary_reference",
            how="left",
        ).with_columns(
            pl.col("provision_allocated").fill_null(0.0),
        )

    # --- SA drawn-first deduction; IRB/Slotting: no deduction ---
    is_sa = pl.col("approach") == ApproachType.SA.value

    floored_drawn = pl.col("drawn_amount").clip(lower_bound=0.0)

    # provision_on_drawn: min(allocated, floored_drawn) for SA; 0 for IRB
    provision_on_drawn = (
        pl.when(is_sa)
        .then(pl.min_horizontal("provision_allocated", floored_drawn))
        .otherwise(pl.lit(0.0))
    )

    exposures = exposures.with_columns(
        provision_on_drawn.alias("provision_on_drawn"),
    )

    # provision_on_nominal: min(remaining, nominal) for SA; 0 for IRB
    remaining = (pl.col("provision_allocated") - pl.col("provision_on_drawn")).clip(lower_bound=0.0)
    provision_on_nominal = (
        pl.when(is_sa)
        .then(pl.min_horizontal(remaining, pl.col("nominal_amount")))
        .otherwise(pl.lit(0.0))
    )

    exposures = exposures.with_columns(
        provision_on_nominal.alias("provision_on_nominal"),
    )

    # provision_deducted = on_drawn + on_nominal
    exposures = exposures.with_columns(
        (pl.col("provision_on_drawn") + pl.col("provision_on_nominal")).alias("provision_deducted"),
    )

    # nominal_after_provision for CCF: nominal - provision_on_nominal
    exposures = exposures.with_columns(
        (pl.col("nominal_amount") - pl.col("provision_on_nominal")).alias(
            "nominal_after_provision"
        ),
    )

    return exposures

CRR Art. 112 — Exposure classes

apply_risk_weights — src/rwa_calc/engine/sa/risk_weights.py:307
@cites("CRR Art. 112")
def apply_risk_weights(
    lf: pl.LazyFrame,
    config: CalculationConfig,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """Look up and apply risk weights based on exposure class.

    Orchestrates the three-phase SA risk weight assignment:
        1. Setup — ensure columns, derive maturity, classify, join CQS table
        2. Framework-specific when/then overrides (CRR vs Basel 3.1)
        3. Cleanup — sovereign floor, defaulted RW blending, drop temp cols

    Branches in the override chain are order-sensitive (first match wins);
    the framework override helpers apply them in the sequence prescribed
    by the regulation.
    """
    exposures, uc, is_domestic_currency = _prepare_risk_weight_lookup(lf, config, pack=pack)

    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
    if resolved_pack.feature("sa_revised_risk_weight_overrides"):
        exposures = _apply_b31_risk_weight_overrides(exposures, uc, is_domestic_currency, config)
    else:
        exposures = _apply_crr_risk_weight_overrides(exposures, uc, is_domestic_currency)

    # Art. 121(6) (CRR) / CRE20.22 (Basel 3.1): Sovereign RW floor for
    # FX-denominated unrated institution exposures. Exception:
    # self-liquidating trade items with original maturity <= 1yr.
    exposures = _apply_sovereign_floor_for_institutions(exposures, is_domestic_currency)

    # Art. 127 defaulted risk weight (secured/unsecured split). Runs after
    # the base RW when-chain so defaulted exposures have their non-defaulted
    # base RW available for blending with collateral coverage.
    exposures = _apply_defaulted_risk_weight(exposures, config, pack=resolved_pack)

    # Drop temporary columns used only during risk-weight application.
    schema_names = exposures.collect_schema().names()
    temp_cols = [
        "_lookup_class",
        "_lookup_cqs",
        "_upper_class",
        "_cqs_risk_weight",
        "_sovereign_rw",
        "risk_weight_rw",
    ]
    return exposures.drop([c for c in temp_cols if c in schema_names])
classify — src/rwa_calc/engine/stages/classify/classifier.py:104
    @cites("CRR Art. 112")
    @cites("CRR Art. 147")
    def classify(
        self,
        data: ResolvedHierarchyBundle,
        config: CalculationConfig,
        *,
        pack: ResolvedRulepack | None = None,
    ) -> ClassifiedExposuresBundle:
        """
        Classify exposures and split by approach.

        Args:
            data: Hierarchy-resolved data from HierarchyResolver
            config: Calculation configuration

        Returns:
            ClassifiedExposuresBundle with exposures split by approach
        """
        resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack

        # Reads top-to-bottom as a recipe; each helper owns one regulatory
        # concept. See the sibling sub-modules for per-step regulatory
        # references.
        exposures = add_counterparty_attributes(
            data.exposures,
            data.counterparty_lookup.counterparties,
        )
        exposures = join_specialised_lending(exposures, data.specialised_lending)

        # Single schema snapshot — used by the remaining schema-conditional
        # helpers (non-contract scratch columns and the EU-sovereign currency
        # probe) without re-scanning the LazyFrame. Contract columns
        # (hierarchy_exit / cp_lookup_* / raw_model_permissions) need no
        # presence gate — sealed inputs always carry them.
        schema_names = set(exposures.collect_schema().names())

        classification_errors = collect_input_warnings(data, config, pack=resolved_pack)

        classified = derive_independent_flags(exposures, config, schema_names, pack=resolved_pack)
        classified = classify_exposure_subtypes(classified, config, pack=resolved_pack)
        classified = reclassify_corporate_to_retail(
            classified, config, schema_names, pack=resolved_pack
        )
        classified = flag_property_reclassification_candidates(
            classified, config, schema_names, pack=resolved_pack
        )
        classified = sync_irb_exposure_class(classified)

        has_model_permissions = data.model_permissions is not None
        if data.model_permissions is not None:
            classified = resolve_model_permissions(classified, data.model_permissions)

        classified = assign_approach(
            classified,
            config,
            schema_names,
            has_model_permissions=has_model_permissions,
            pack=resolved_pack,
        )
        classified = derive_exposure_subclass(classified, config, pack=resolved_pack)

        # Stage-exit edge (producer-side): the diagnostic emits below run
        # against in-memory data instead of re-executing the upstream lazy
        # plan, and CRMProcessor receives an eager-backed frame. Laziness is
        # strictly intra-stage (migration Phase 1).
        classified = materialise_edge(classified, config, "classifier_exit")

        classification_errors.extend(collect_beel_on_non_defaulted_warnings(classified))
        if has_model_permissions:
            classification_errors.extend(emit_model_permission_diagnostics(classified))

        # Producer seal (Phase 3): validates the contract and strips
        # intra-stage scratch (including _model_permission_diagnostic) —
        # pure plan ops over the eager-backed frame. CCR runs carry the
        # SA-CCR provenance columns through, so the contract is selected
        # by the input frame's brand.
        exit_edge = (
            CLASSIFIER_EXIT_CCR_EDGE
            if sealed_edge_of(data.exposures) == "ccr_exit"
            else CLASSIFIER_EXIT_EDGE
        )
        classified = seal(classified, exit_edge)

        return self._build_bundle(classified, data, classification_errors)

CRR Art. 113 — Calculation of risk-weighted exposure amounts

calculate_rwa — src/rwa_calc/engine/sa/factors_output.py:50
@cites("CRR Art. 113")
def calculate_rwa(lf: pl.LazyFrame) -> pl.LazyFrame:
    """Compute pre-factor RWA = EAD x Risk Weight.

    Emits ``rwa_pre_factor`` for downstream supporting-factor scaling.

    References:
    - CRR Art. 113(1)-(5): general rule for SA risk-weighted exposure amounts.
    """
    return lf.with_columns(
        (pl.col("ead_final") * pl.col("risk_weight")).alias("rwa_pre_factor"),
    )

CRR Art. 114 — Exposures to central governments or central banks

build_eu_domestic_currency_expr — src/rwa_calc/engine/eu_sovereign.py:46
@cites("CRR Art. 114")
@cites("CRR Art. 141")
def build_eu_domestic_currency_expr(
    country_col: str,
    currency_col: str | pl.Expr = "currency",
) -> pl.Expr:
    """
    Build a Polars expression that checks if an exposure is to an EU member
    state's central government/central bank denominated in that state's
    domestic currency.

    Uses replace_strict to map country code → domestic currency, then compares
    with the exposure denomination currency.

    Args:
        country_col: Column name containing the ISO country code
        currency_col: Column name (str) or Polars expression for the
            exposure's denomination currency. A string is wrapped in
            ``pl.col(...)``. Callers operating on a post-FX-conversion
            LazyFrame should pass ``denomination_currency_expr(...)`` so the
            original (pre-conversion) currency is compared — not the reporting
            currency.

    Returns:
        Boolean Polars expression: True when country is EU and currency matches
        that country's domestic currency.
    """
    currency_expr = pl.col(currency_col) if isinstance(currency_col, str) else currency_col
    return (
        pl.col(country_col)
        .fill_null("")
        .replace_strict(_EU_COUNTRY_DOMESTIC_CURRENCY, default=None)
        .eq(currency_expr)
    )
build_domestic_cgcb_guarantor_expr — src/rwa_calc/engine/eu_sovereign.py:82
@cites("CRR Art. 114")
def build_domestic_cgcb_guarantor_expr(
    country_col: str,
    currency_col: str | pl.Expr,
) -> pl.Expr:
    """
    Build a Polars expression that identifies a domestic-currency CGCB guarantor
    under CRR Art. 114(4) and Art. 114(7) (Basel 3.1 preservation).

    Combines the UK (GB/GBP) and EU (member state / member-state-domestic-currency)
    branches into a single boolean expression.

    Callers pass the guarantor's country code column and the currency column to
    test against. For guarantee substitution (Art. 215-217) the currency column
    should be the **guarantee** currency — the Art. 233(3) 8% FX haircut handles
    any mismatch between the guarantee and the underlying exposure separately.

    Args:
        country_col: Column name containing the guarantor's ISO country code.
        currency_col: Column name (str) or Polars expression for the currency
            to test against the guarantor's domestic currency.

    Returns:
        Boolean Polars expression: True when the guarantor is UK CGCB in GBP or
        an EU-member CGCB in that member state's domestic currency.
    """
    currency_expr = pl.col(currency_col) if isinstance(currency_col, str) else currency_col
    is_uk_domestic = (pl.col(country_col).fill_null("") == "GB") & (currency_expr == "GBP")
    is_eu_domestic = build_eu_domestic_currency_expr(country_col, currency_expr)
    return is_uk_domestic | is_eu_domestic
build_guarantor_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:127
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("CRR Art. 118")
@cites("CRR Art. 235")
def build_guarantor_rw_expr(
    *,
    exposure_class_col: str,
    entity_type_col: str,
    cqs_col: str,
    country_code_col: str,
    ccp_client_cleared_col: str,
    scra_grade_col: str,
    is_basel_3_1: bool,
    domestic_cgcb_expr: pl.Expr | None = None,
    short_term_flag_col: str | None = None,
    no_guarantee_expr: pl.Expr | None = None,
) -> pl.Expr:
    """Build the full when/then chain that maps a guarantor to its SA RW.

    Dispatches on the guarantor's SA exposure class (derived from
    ``ENTITY_TYPE_TO_SA_CLASS`` by the caller — e.g. the CRM processor)
    rather than regex on entity_type, ensuring all valid entity types are
    covered. Reproduces the SA-side reference chain
    (``engine/sa/rw_adjustments.py::_build_guarantor_rw_expr``) branch-for-branch.

    Branch order (first match wins):
        no guarantee -> null (only when ``no_guarantee_expr`` is supplied)
        domestic CGCB sovereign (Art. 114(4)/(7)) -> 0%
        CGCB CQS table (Art. 114 Table 1)
        CCP (CRR Art. 306, CRE54.14-15)
        International Organisation (Art. 118) -> 0%
        Named MDB (Art. 117(2)) -> 0%
        MDB Table 2B (Art. 117(1))
        Institution (ECRA / SCRA via build_institution_guarantor_rw_expr —
            short-term Art. 120(2) Table 4 when ``short_term_flag_col``
            evaluates True, otherwise long-term Table 3)
        PSE (Art. 116(2) Table 2A, sovereign-derived for unrated)
        RGLA (Art. 115(1)(b) Table 1B, sovereign-derived for unrated)
        Corporate (Art. 122 corporate CQS table)
        else -> null (no substitution)

    The unrated PSE / RGLA fallback is the documented SA-side approximation
    (no guarantor sovereign-CQS join exists in the CRM column production):
    a GB guarantor receives the 20% RGLA / PSE domestic-currency treatment,
    any other country the conservative 100% unrated default — NOT the full
    Art. 116(1) Table 2 / Art. 115(1)(a) Table 1A sovereign-derived lookup.

    Args:
        exposure_class_col: Name of the guarantor SA exposure-class column
            (e.g. ``guarantor_exposure_class``).
        entity_type_col: Name of the guarantor entity-type column — used for
            the CCP override and the named-MDB (``mdb_named``) carve-out.
        cqs_col: Name of the integer guarantor CQS column.
        country_code_col: Name of the guarantor country-code column — drives
            the unrated PSE / RGLA GB-vs-other approximation.
        ccp_client_cleared_col: Name of the Boolean client-cleared flag
            column (null -> proprietary 2%).
        scra_grade_col: Name of the guarantor SCRA-grade column threaded
            into ``build_institution_guarantor_rw_expr`` for the B31 unrated
            institution dispatch.
        is_basel_3_1: Select PS1/26 tables (institution ECRA / corporate
            Table 6) when True, CRR tables when False. PSE / RGLA / MDB /
            IO / CCP values are framework-identical.
        domestic_cgcb_expr: Caller-supplied Art. 114(4)/(7) domestic-currency
            test (SA and IRB derive domesticity differently). ``None``
            disables the domestic 0% branch (treated as never-domestic).
        short_term_flag_col: Optional Boolean column routing institution
            guarantors to the Art. 120(2) Table 4 short-term dicts. The IRB
            chain passes ``None`` today.
        no_guarantee_expr: Caller-owned leading guard — rows where it
            evaluates True yield null (no substitution priced). ``None``
            omits the guard (the chain prices every row).

    Returns:
        Float64 Polars expression evaluating to the guarantor's SA RW, or
        null where no substitution treatment exists.
    """
    gec = pl.col(exposure_class_col).fill_null("")
    # PSE/RGLA Art. 116(2)/115(1)(b) unrated fallback: domestic-GB guarantors
    # get the RGLA/PSE 20% domestic-currency treatment; otherwise the
    # conservative 100% PSE/RGLA unrated default applies.
    sovereign_derived_unrated = _pse_rgla_unrated_fallback_expr(country_code_col)

    cgcb_unrated = float(_CGCB_RW[CQS.UNRATED])

    is_domestic_guarantor = domestic_cgcb_expr if domestic_cgcb_expr is not None else pl.lit(False)
    skip_substitution = no_guarantee_expr if no_guarantee_expr is not None else pl.lit(False)

    return (
        pl.when(skip_substitution)
        .then(pl.lit(None).cast(pl.Float64))
        # Art. 114(4)/(7): Domestic sovereign -> 0% regardless of CQS.
        .when((gec == "central_govt_central_bank") & is_domestic_guarantor)
        .then(pl.lit(0.0))
        # CGCB guarantors via CQS (Table 1 — sovereign weights).
        .when(gec == "central_govt_central_bank")
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _CGCB_RW,
                cgcb_unrated,
            )
        )
        # CCP guarantors: 2% proprietary / 4% client-cleared
        # (CRR Art. 306, CRE54.14-15) — overrides institution CQS weights.
        .when(pl.col(entity_type_col) == "ccp")
        .then(
            pl.when(pl.col(ccp_client_cleared_col).fill_null(False))
            .then(pl.lit(_QCCP_CLIENT_CLEARED_RW))
            .otherwise(pl.lit(_QCCP_PROPRIETARY_RW))
        )
        # International Organisation (Art. 118): 0% unconditional.
        .when(gec == "international_organisation")
        .then(pl.lit(float(_IO_ZERO_RW)))
        # Named MDB (Art. 117(2)): 0% unconditional.
        .when((gec == "mdb") & (pl.col(entity_type_col).fill_null("") == "mdb_named"))
        .then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
        # Rated / unrated non-named MDB — Table 2B (Art. 117(1)).
        .when(gec == "mdb")
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _MDB_RW,
                float(_MDB_UNRATED_RW),
            )
        )
        # Institution guarantors — RW driven from institution_rw_crr /
        # institution_rw_b31_ecra so the pack remains the single source of
        # truth. When the short-term flag evaluates True (CRR/PS1/26
        # Art. 120(2)), the short-term Table 4 dicts apply instead.
        .when(gec == "institution")
        .then(
            build_institution_guarantor_rw_expr(
                cqs_col,
                is_basel_3_1,
                short_term_flag_col=short_term_flag_col,
                scra_grade_col=scra_grade_col,
            )
        )
        # PSE guarantors — Art. 116(2) Table 2A for rated, sovereign-derived for unrated.
        .when(gec == "pse")
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _PSE_OWN_RW,
                sovereign_derived_unrated,
            )
        )
        # RGLA guarantors — Art. 115(1)(b) Table 1B for rated, sovereign-derived for unrated.
        .when(gec == "rgla")
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _RGLA_OWN_RW,
                sovereign_derived_unrated,
            )
        )
        # Corporate guarantors — Art. 122 corporate CQS table.
        # Basel 3.1 (PRA PS1/26 Art. 122(2) Table 6): CQS3 = 75% (CRR: 100%);
        # PRA retains CQS5 = 150%. Gated on framework so CRR runs are
        # unchanged.
        .when(gec.is_in(["corporate", "corporate_sme"]))
        .then(build_corporate_guarantor_rw_expr(cqs_col, is_basel_3_1))
        .otherwise(pl.lit(None).cast(pl.Float64))
    )
build_entity_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:296
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("CRR Art. 118")
@cites("CRR Art. 122")
@cites("CRR Art. 123")
def build_entity_rw_expr(
    *,
    entity_type_col: str,
    cqs_col: str,
    is_basel_3_1: bool,
    country_code_col: str | None = None,
) -> pl.Expr:
    """Build the entity-level SA risk-weight preview expression.

    Compiled by the hierarchy facility-share selection
    (``engine/stages/hierarchy/facility_undrawn.py::
    _derive_facility_share_counterparty``) to rank candidate counterparties
    by SA-equivalent risk weight. The preview is non-binding: the chosen
    counterparty still flows through the full classifier and SA/IRB pipeline
    downstream. Keeping the preview SA-only avoids a circular dependency with
    the classifier's IRB approach gating.

    Routes the lowercased ``entity_type`` through the SA exposure-class
    buckets (``ENTITY_TYPES_BY_SA_CLASS``) and maps CQS -> RW via the same
    table branches as :func:`build_guarantor_rw_expr`:

        sovereign (CGCB CQS Table 1, Art. 114)
        International Organisation (Art. 118) -> 0%
        Named MDB (Art. 117(2)) -> 0%
        MDB Table 2B (Art. 117(1))
        Institution (Art. 120 Table 3 / PS1/26 ECRA via
            ``build_institution_guarantor_rw_expr``)
        PSE (Art. 116(2) Table 2A, GB/other approximation for unrated)
        RGLA (Art. 115(1)(b) Table 1B, GB/other approximation for unrated)
        Corporate + covered bond (Art. 122 CRR Table 5 — see note below)
        Retail (Art. 123 flat 75%)
        High risk (Art. 128 flat 150%)
        else -> 1.0 (conservative preview default for unmatched entity
            types, e.g. equity / other items)

    Branch-parity notes (the pre-existing preview branches are preserved
    value-for-value):

    - The corporate branch always prices from ``corporate_risk_weights``
      (CRR Art. 122 Table 5), NOT the Basel 3.1 Table 6 — matching the
      historical preview. Covered bonds use the corporate-equivalent CQS RWs
      in the preview; the precise covered-bond table only applies in real
      SA pricing.
    - The unrated PSE / RGLA fallback is the documented SA-side
      approximation (see :func:`build_guarantor_rw_expr`): GB -> 20%
      domestic-currency treatment, other / unknown country -> 100% unrated
      default. When ``country_code_col`` is ``None`` the 100% default
      applies unconditionally.

    Args:
        entity_type_col: Name of the entity-type column. Null-filled to ""
            and lowercased before bucket routing.
        cqs_col: Name of the integer CQS column; null / out-of-range values
            fall to each table's unrated default.
        is_basel_3_1: Select the PS1/26 institution ECRA table when True,
            CRR Art. 120 Table 3 when False. All other preview branches are
            framework-identical by construction (see corporate note above).
        country_code_col: Optional name of the country-code column driving
            the unrated PSE / RGLA GB-vs-other approximation. ``None`` falls
            back to the conservative 100% unrated default.

    Returns:
        Float64 Polars expression evaluating to the entity's SA-equivalent
        preview risk weight (never null — unmatched entity types yield 1.0).
    """
    et = pl.col(entity_type_col).fill_null("").str.to_lowercase()

    sovereign_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CENTRAL_GOVT_CENTRAL_BANK.value])
    io_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INTERNATIONAL_ORGANISATION.value])
    mdb_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.MDB.value])
    institution_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INSTITUTION.value])
    pse_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.PSE.value])
    rgla_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RGLA.value])
    corporate_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CORPORATE.value])
    covered_bond_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.COVERED_BOND.value])
    retail_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RETAIL_OTHER.value])
    high_risk_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.HIGH_RISK.value])

    unrated_pse_rgla = _pse_rgla_unrated_fallback_expr(country_code_col)

    return (
        # CGCB (Art. 114 Table 1 — sovereign weights).
        pl.when(et.is_in(sovereign_types))
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _CGCB_RW,
                float(_CGCB_RW[CQS.UNRATED]),
            )
        )
        # International Organisation (Art. 118): 0% unconditional.
        .when(et.is_in(io_types))
        .then(pl.lit(float(_IO_ZERO_RW)))
        # Named MDB (Art. 117(2)): 0% unconditional — carved out ahead of Table 2B.
        .when(et == "mdb_named")
        .then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
        # Rated / unrated non-named MDB — Table 2B (Art. 117(1)).
        .when(et.is_in(mdb_types))
        .then(_cqs_table_lookup_expr(cqs_col, _MDB_RW, float(_MDB_UNRATED_RW)))
        # Institution — Art. 120 Table 3 / PS1/26 ECRA via the shared builder
        # so the pack remains the single source of truth.
        .when(et.is_in(institution_types))
        .then(build_institution_guarantor_rw_expr(cqs_col, is_basel_3_1))
        # PSE — Art. 116(2) Table 2A for rated, GB/other approximation for unrated.
        .when(et.is_in(pse_types))
        .then(_cqs_table_lookup_expr(cqs_col, _PSE_OWN_RW, unrated_pse_rgla))
        # RGLA — Art. 115(1)(b) Table 1B for rated, GB/other approximation for unrated.
        .when(et.is_in(rgla_types))
        .then(_cqs_table_lookup_expr(cqs_col, _RGLA_OWN_RW, unrated_pse_rgla))
        # Corporate + covered bond — CRR Art. 122 Table 5 (preview parity:
        # not framework-switched; see docstring).
        .when(et.is_in(corporate_types + covered_bond_types))
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _CORPORATE_RW,
                float(_CORPORATE_RW[CQS.UNRATED]),
            )
        )
        # Retail (Art. 123): flat 75%.
        .when(et.is_in(retail_types))
        .then(pl.lit(_RETAIL_RISK_WEIGHT))
        # High-risk items (Art. 128): flat 150%.
        .when(et.is_in(high_risk_types))
        .then(pl.lit(_HIGH_RISK_RW))
        # Conservative preview default for unmatched entity types.
        .otherwise(pl.lit(1.0))
    )

CRR Art. 115 — Exposures to regional governments or local authorities

_create_rgla_df — src/rwa_calc/engine/sa/crr_risk_weight_tables.py:277
@cites("CRR Art. 115")
def _create_rgla_df() -> pl.DataFrame:
    """Create RGLA risk weight lookup DataFrame (Art. 115(1)(b), Table 1B, own-rating).

    Rated RGLAs join against this table via their own CQS.
    Unrated RGLAs use sovereign-derived treatment handled in the SA calculator.
    UK devolved govts (0%) and UK local authorities (20%) are overrides in the calculator.
    """
    return _build_cqs_rw_df(
        RGLA_RISK_WEIGHTS_OWN_RATING,
        "RGLA",
        order=_CQS_ORDER_RATED_ONLY,
    )
build_guarantor_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:128
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("CRR Art. 118")
@cites("CRR Art. 235")
def build_guarantor_rw_expr(
    *,
    exposure_class_col: str,
    entity_type_col: str,
    cqs_col: str,
    country_code_col: str,
    ccp_client_cleared_col: str,
    scra_grade_col: str,
    is_basel_3_1: bool,
    domestic_cgcb_expr: pl.Expr | None = None,
    short_term_flag_col: str | None = None,
    no_guarantee_expr: pl.Expr | None = None,
) -> pl.Expr:
    """Build the full when/then chain that maps a guarantor to its SA RW.

    Dispatches on the guarantor's SA exposure class (derived from
    ``ENTITY_TYPE_TO_SA_CLASS`` by the caller — e.g. the CRM processor)
    rather than regex on entity_type, ensuring all valid entity types are
    covered. Reproduces the SA-side reference chain
    (``engine/sa/rw_adjustments.py::_build_guarantor_rw_expr``) branch-for-branch.

    Branch order (first match wins):
        no guarantee -> null (only when ``no_guarantee_expr`` is supplied)
        domestic CGCB sovereign (Art. 114(4)/(7)) -> 0%
        CGCB CQS table (Art. 114 Table 1)
        CCP (CRR Art. 306, CRE54.14-15)
        International Organisation (Art. 118) -> 0%
        Named MDB (Art. 117(2)) -> 0%
        MDB Table 2B (Art. 117(1))
        Institution (ECRA / SCRA via build_institution_guarantor_rw_expr —
            short-term Art. 120(2) Table 4 when ``short_term_flag_col``
            evaluates True, otherwise long-term Table 3)
        PSE (Art. 116(2) Table 2A, sovereign-derived for unrated)
        RGLA (Art. 115(1)(b) Table 1B, sovereign-derived for unrated)
        Corporate (Art. 122 corporate CQS table)
        else -> null (no substitution)

    The unrated PSE / RGLA fallback is the documented SA-side approximation
    (no guarantor sovereign-CQS join exists in the CRM column production):
    a GB guarantor receives the 20% RGLA / PSE domestic-currency treatment,
    any other country the conservative 100% unrated default — NOT the full
    Art. 116(1) Table 2 / Art. 115(1)(a) Table 1A sovereign-derived lookup.

    Args:
        exposure_class_col: Name of the guarantor SA exposure-class column
            (e.g. ``guarantor_exposure_class``).
        entity_type_col: Name of the guarantor entity-type column — used for
            the CCP override and the named-MDB (``mdb_named``) carve-out.
        cqs_col: Name of the integer guarantor CQS column.
        country_code_col: Name of the guarantor country-code column — drives
            the unrated PSE / RGLA GB-vs-other approximation.
        ccp_client_cleared_col: Name of the Boolean client-cleared flag
            column (null -> proprietary 2%).
        scra_grade_col: Name of the guarantor SCRA-grade column threaded
            into ``build_institution_guarantor_rw_expr`` for the B31 unrated
            institution dispatch.
        is_basel_3_1: Select PS1/26 tables (institution ECRA / corporate
            Table 6) when True, CRR tables when False. PSE / RGLA / MDB /
            IO / CCP values are framework-identical.
        domestic_cgcb_expr: Caller-supplied Art. 114(4)/(7) domestic-currency
            test (SA and IRB derive domesticity differently). ``None``
            disables the domestic 0% branch (treated as never-domestic).
        short_term_flag_col: Optional Boolean column routing institution
            guarantors to the Art. 120(2) Table 4 short-term dicts. The IRB
            chain passes ``None`` today.
        no_guarantee_expr: Caller-owned leading guard — rows where it
            evaluates True yield null (no substitution priced). ``None``
            omits the guard (the chain prices every row).

    Returns:
        Float64 Polars expression evaluating to the guarantor's SA RW, or
        null where no substitution treatment exists.
    """
    gec = pl.col(exposure_class_col).fill_null("")
    # PSE/RGLA Art. 116(2)/115(1)(b) unrated fallback: domestic-GB guarantors
    # get the RGLA/PSE 20% domestic-currency treatment; otherwise the
    # conservative 100% PSE/RGLA unrated default applies.
    sovereign_derived_unrated = _pse_rgla_unrated_fallback_expr(country_code_col)

    cgcb_unrated = float(_CGCB_RW[CQS.UNRATED])

    is_domestic_guarantor = domestic_cgcb_expr if domestic_cgcb_expr is not None else pl.lit(False)
    skip_substitution = no_guarantee_expr if no_guarantee_expr is not None else pl.lit(False)

    return (
        pl.when(skip_substitution)
        .then(pl.lit(None).cast(pl.Float64))
        # Art. 114(4)/(7): Domestic sovereign -> 0% regardless of CQS.
        .when((gec == "central_govt_central_bank") & is_domestic_guarantor)
        .then(pl.lit(0.0))
        # CGCB guarantors via CQS (Table 1 — sovereign weights).
        .when(gec == "central_govt_central_bank")
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _CGCB_RW,
                cgcb_unrated,
            )
        )
        # CCP guarantors: 2% proprietary / 4% client-cleared
        # (CRR Art. 306, CRE54.14-15) — overrides institution CQS weights.
        .when(pl.col(entity_type_col) == "ccp")
        .then(
            pl.when(pl.col(ccp_client_cleared_col).fill_null(False))
            .then(pl.lit(_QCCP_CLIENT_CLEARED_RW))
            .otherwise(pl.lit(_QCCP_PROPRIETARY_RW))
        )
        # International Organisation (Art. 118): 0% unconditional.
        .when(gec == "international_organisation")
        .then(pl.lit(float(_IO_ZERO_RW)))
        # Named MDB (Art. 117(2)): 0% unconditional.
        .when((gec == "mdb") & (pl.col(entity_type_col).fill_null("") == "mdb_named"))
        .then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
        # Rated / unrated non-named MDB — Table 2B (Art. 117(1)).
        .when(gec == "mdb")
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _MDB_RW,
                float(_MDB_UNRATED_RW),
            )
        )
        # Institution guarantors — RW driven from institution_rw_crr /
        # institution_rw_b31_ecra so the pack remains the single source of
        # truth. When the short-term flag evaluates True (CRR/PS1/26
        # Art. 120(2)), the short-term Table 4 dicts apply instead.
        .when(gec == "institution")
        .then(
            build_institution_guarantor_rw_expr(
                cqs_col,
                is_basel_3_1,
                short_term_flag_col=short_term_flag_col,
                scra_grade_col=scra_grade_col,
            )
        )
        # PSE guarantors — Art. 116(2) Table 2A for rated, sovereign-derived for unrated.
        .when(gec == "pse")
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _PSE_OWN_RW,
                sovereign_derived_unrated,
            )
        )
        # RGLA guarantors — Art. 115(1)(b) Table 1B for rated, sovereign-derived for unrated.
        .when(gec == "rgla")
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _RGLA_OWN_RW,
                sovereign_derived_unrated,
            )
        )
        # Corporate guarantors — Art. 122 corporate CQS table.
        # Basel 3.1 (PRA PS1/26 Art. 122(2) Table 6): CQS3 = 75% (CRR: 100%);
        # PRA retains CQS5 = 150%. Gated on framework so CRR runs are
        # unchanged.
        .when(gec.is_in(["corporate", "corporate_sme"]))
        .then(build_corporate_guarantor_rw_expr(cqs_col, is_basel_3_1))
        .otherwise(pl.lit(None).cast(pl.Float64))
    )
build_entity_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:297
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("CRR Art. 118")
@cites("CRR Art. 122")
@cites("CRR Art. 123")
def build_entity_rw_expr(
    *,
    entity_type_col: str,
    cqs_col: str,
    is_basel_3_1: bool,
    country_code_col: str | None = None,
) -> pl.Expr:
    """Build the entity-level SA risk-weight preview expression.

    Compiled by the hierarchy facility-share selection
    (``engine/stages/hierarchy/facility_undrawn.py::
    _derive_facility_share_counterparty``) to rank candidate counterparties
    by SA-equivalent risk weight. The preview is non-binding: the chosen
    counterparty still flows through the full classifier and SA/IRB pipeline
    downstream. Keeping the preview SA-only avoids a circular dependency with
    the classifier's IRB approach gating.

    Routes the lowercased ``entity_type`` through the SA exposure-class
    buckets (``ENTITY_TYPES_BY_SA_CLASS``) and maps CQS -> RW via the same
    table branches as :func:`build_guarantor_rw_expr`:

        sovereign (CGCB CQS Table 1, Art. 114)
        International Organisation (Art. 118) -> 0%
        Named MDB (Art. 117(2)) -> 0%
        MDB Table 2B (Art. 117(1))
        Institution (Art. 120 Table 3 / PS1/26 ECRA via
            ``build_institution_guarantor_rw_expr``)
        PSE (Art. 116(2) Table 2A, GB/other approximation for unrated)
        RGLA (Art. 115(1)(b) Table 1B, GB/other approximation for unrated)
        Corporate + covered bond (Art. 122 CRR Table 5 — see note below)
        Retail (Art. 123 flat 75%)
        High risk (Art. 128 flat 150%)
        else -> 1.0 (conservative preview default for unmatched entity
            types, e.g. equity / other items)

    Branch-parity notes (the pre-existing preview branches are preserved
    value-for-value):

    - The corporate branch always prices from ``corporate_risk_weights``
      (CRR Art. 122 Table 5), NOT the Basel 3.1 Table 6 — matching the
      historical preview. Covered bonds use the corporate-equivalent CQS RWs
      in the preview; the precise covered-bond table only applies in real
      SA pricing.
    - The unrated PSE / RGLA fallback is the documented SA-side
      approximation (see :func:`build_guarantor_rw_expr`): GB -> 20%
      domestic-currency treatment, other / unknown country -> 100% unrated
      default. When ``country_code_col`` is ``None`` the 100% default
      applies unconditionally.

    Args:
        entity_type_col: Name of the entity-type column. Null-filled to ""
            and lowercased before bucket routing.
        cqs_col: Name of the integer CQS column; null / out-of-range values
            fall to each table's unrated default.
        is_basel_3_1: Select the PS1/26 institution ECRA table when True,
            CRR Art. 120 Table 3 when False. All other preview branches are
            framework-identical by construction (see corporate note above).
        country_code_col: Optional name of the country-code column driving
            the unrated PSE / RGLA GB-vs-other approximation. ``None`` falls
            back to the conservative 100% unrated default.

    Returns:
        Float64 Polars expression evaluating to the entity's SA-equivalent
        preview risk weight (never null — unmatched entity types yield 1.0).
    """
    et = pl.col(entity_type_col).fill_null("").str.to_lowercase()

    sovereign_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CENTRAL_GOVT_CENTRAL_BANK.value])
    io_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INTERNATIONAL_ORGANISATION.value])
    mdb_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.MDB.value])
    institution_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INSTITUTION.value])
    pse_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.PSE.value])
    rgla_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RGLA.value])
    corporate_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CORPORATE.value])
    covered_bond_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.COVERED_BOND.value])
    retail_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RETAIL_OTHER.value])
    high_risk_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.HIGH_RISK.value])

    unrated_pse_rgla = _pse_rgla_unrated_fallback_expr(country_code_col)

    return (
        # CGCB (Art. 114 Table 1 — sovereign weights).
        pl.when(et.is_in(sovereign_types))
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _CGCB_RW,
                float(_CGCB_RW[CQS.UNRATED]),
            )
        )
        # International Organisation (Art. 118): 0% unconditional.
        .when(et.is_in(io_types))
        .then(pl.lit(float(_IO_ZERO_RW)))
        # Named MDB (Art. 117(2)): 0% unconditional — carved out ahead of Table 2B.
        .when(et == "mdb_named")
        .then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
        # Rated / unrated non-named MDB — Table 2B (Art. 117(1)).
        .when(et.is_in(mdb_types))
        .then(_cqs_table_lookup_expr(cqs_col, _MDB_RW, float(_MDB_UNRATED_RW)))
        # Institution — Art. 120 Table 3 / PS1/26 ECRA via the shared builder
        # so the pack remains the single source of truth.
        .when(et.is_in(institution_types))
        .then(build_institution_guarantor_rw_expr(cqs_col, is_basel_3_1))
        # PSE — Art. 116(2) Table 2A for rated, GB/other approximation for unrated.
        .when(et.is_in(pse_types))
        .then(_cqs_table_lookup_expr(cqs_col, _PSE_OWN_RW, unrated_pse_rgla))
        # RGLA — Art. 115(1)(b) Table 1B for rated, GB/other approximation for unrated.
        .when(et.is_in(rgla_types))
        .then(_cqs_table_lookup_expr(cqs_col, _RGLA_OWN_RW, unrated_pse_rgla))
        # Corporate + covered bond — CRR Art. 122 Table 5 (preview parity:
        # not framework-switched; see docstring).
        .when(et.is_in(corporate_types + covered_bond_types))
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _CORPORATE_RW,
                float(_CORPORATE_RW[CQS.UNRATED]),
            )
        )
        # Retail (Art. 123): flat 75%.
        .when(et.is_in(retail_types))
        .then(pl.lit(_RETAIL_RISK_WEIGHT))
        # High-risk items (Art. 128): flat 150%.
        .when(et.is_in(high_risk_types))
        .then(pl.lit(_HIGH_RISK_RW))
        # Conservative preview default for unmatched entity types.
        .otherwise(pl.lit(1.0))
    )

CRR Art. 116 — Exposures to public sector entities

_create_pse_df — src/rwa_calc/engine/sa/crr_risk_weight_tables.py:234
@cites("CRR Art. 116")
def _create_pse_df() -> pl.DataFrame:
    """Create PSE risk weight lookup DataFrame (Art. 116(2), Table 2A, own-rating).

    Rated PSEs join against this table via their own CQS.
    Unrated PSEs use sovereign-derived treatment handled in the SA calculator.
    """
    return _build_cqs_rw_df(
        PSE_RISK_WEIGHTS_OWN_RATING,
        "PSE",
        order=_CQS_ORDER_RATED_ONLY,
    )
build_guarantor_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:129
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("CRR Art. 118")
@cites("CRR Art. 235")
def build_guarantor_rw_expr(
    *,
    exposure_class_col: str,
    entity_type_col: str,
    cqs_col: str,
    country_code_col: str,
    ccp_client_cleared_col: str,
    scra_grade_col: str,
    is_basel_3_1: bool,
    domestic_cgcb_expr: pl.Expr | None = None,
    short_term_flag_col: str | None = None,
    no_guarantee_expr: pl.Expr | None = None,
) -> pl.Expr:
    """Build the full when/then chain that maps a guarantor to its SA RW.

    Dispatches on the guarantor's SA exposure class (derived from
    ``ENTITY_TYPE_TO_SA_CLASS`` by the caller — e.g. the CRM processor)
    rather than regex on entity_type, ensuring all valid entity types are
    covered. Reproduces the SA-side reference chain
    (``engine/sa/rw_adjustments.py::_build_guarantor_rw_expr``) branch-for-branch.

    Branch order (first match wins):
        no guarantee -> null (only when ``no_guarantee_expr`` is supplied)
        domestic CGCB sovereign (Art. 114(4)/(7)) -> 0%
        CGCB CQS table (Art. 114 Table 1)
        CCP (CRR Art. 306, CRE54.14-15)
        International Organisation (Art. 118) -> 0%
        Named MDB (Art. 117(2)) -> 0%
        MDB Table 2B (Art. 117(1))
        Institution (ECRA / SCRA via build_institution_guarantor_rw_expr —
            short-term Art. 120(2) Table 4 when ``short_term_flag_col``
            evaluates True, otherwise long-term Table 3)
        PSE (Art. 116(2) Table 2A, sovereign-derived for unrated)
        RGLA (Art. 115(1)(b) Table 1B, sovereign-derived for unrated)
        Corporate (Art. 122 corporate CQS table)
        else -> null (no substitution)

    The unrated PSE / RGLA fallback is the documented SA-side approximation
    (no guarantor sovereign-CQS join exists in the CRM column production):
    a GB guarantor receives the 20% RGLA / PSE domestic-currency treatment,
    any other country the conservative 100% unrated default — NOT the full
    Art. 116(1) Table 2 / Art. 115(1)(a) Table 1A sovereign-derived lookup.

    Args:
        exposure_class_col: Name of the guarantor SA exposure-class column
            (e.g. ``guarantor_exposure_class``).
        entity_type_col: Name of the guarantor entity-type column — used for
            the CCP override and the named-MDB (``mdb_named``) carve-out.
        cqs_col: Name of the integer guarantor CQS column.
        country_code_col: Name of the guarantor country-code column — drives
            the unrated PSE / RGLA GB-vs-other approximation.
        ccp_client_cleared_col: Name of the Boolean client-cleared flag
            column (null -> proprietary 2%).
        scra_grade_col: Name of the guarantor SCRA-grade column threaded
            into ``build_institution_guarantor_rw_expr`` for the B31 unrated
            institution dispatch.
        is_basel_3_1: Select PS1/26 tables (institution ECRA / corporate
            Table 6) when True, CRR tables when False. PSE / RGLA / MDB /
            IO / CCP values are framework-identical.
        domestic_cgcb_expr: Caller-supplied Art. 114(4)/(7) domestic-currency
            test (SA and IRB derive domesticity differently). ``None``
            disables the domestic 0% branch (treated as never-domestic).
        short_term_flag_col: Optional Boolean column routing institution
            guarantors to the Art. 120(2) Table 4 short-term dicts. The IRB
            chain passes ``None`` today.
        no_guarantee_expr: Caller-owned leading guard — rows where it
            evaluates True yield null (no substitution priced). ``None``
            omits the guard (the chain prices every row).

    Returns:
        Float64 Polars expression evaluating to the guarantor's SA RW, or
        null where no substitution treatment exists.
    """
    gec = pl.col(exposure_class_col).fill_null("")
    # PSE/RGLA Art. 116(2)/115(1)(b) unrated fallback: domestic-GB guarantors
    # get the RGLA/PSE 20% domestic-currency treatment; otherwise the
    # conservative 100% PSE/RGLA unrated default applies.
    sovereign_derived_unrated = _pse_rgla_unrated_fallback_expr(country_code_col)

    cgcb_unrated = float(_CGCB_RW[CQS.UNRATED])

    is_domestic_guarantor = domestic_cgcb_expr if domestic_cgcb_expr is not None else pl.lit(False)
    skip_substitution = no_guarantee_expr if no_guarantee_expr is not None else pl.lit(False)

    return (
        pl.when(skip_substitution)
        .then(pl.lit(None).cast(pl.Float64))
        # Art. 114(4)/(7): Domestic sovereign -> 0% regardless of CQS.
        .when((gec == "central_govt_central_bank") & is_domestic_guarantor)
        .then(pl.lit(0.0))
        # CGCB guarantors via CQS (Table 1 — sovereign weights).
        .when(gec == "central_govt_central_bank")
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _CGCB_RW,
                cgcb_unrated,
            )
        )
        # CCP guarantors: 2% proprietary / 4% client-cleared
        # (CRR Art. 306, CRE54.14-15) — overrides institution CQS weights.
        .when(pl.col(entity_type_col) == "ccp")
        .then(
            pl.when(pl.col(ccp_client_cleared_col).fill_null(False))
            .then(pl.lit(_QCCP_CLIENT_CLEARED_RW))
            .otherwise(pl.lit(_QCCP_PROPRIETARY_RW))
        )
        # International Organisation (Art. 118): 0% unconditional.
        .when(gec == "international_organisation")
        .then(pl.lit(float(_IO_ZERO_RW)))
        # Named MDB (Art. 117(2)): 0% unconditional.
        .when((gec == "mdb") & (pl.col(entity_type_col).fill_null("") == "mdb_named"))
        .then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
        # Rated / unrated non-named MDB — Table 2B (Art. 117(1)).
        .when(gec == "mdb")
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _MDB_RW,
                float(_MDB_UNRATED_RW),
            )
        )
        # Institution guarantors — RW driven from institution_rw_crr /
        # institution_rw_b31_ecra so the pack remains the single source of
        # truth. When the short-term flag evaluates True (CRR/PS1/26
        # Art. 120(2)), the short-term Table 4 dicts apply instead.
        .when(gec == "institution")
        .then(
            build_institution_guarantor_rw_expr(
                cqs_col,
                is_basel_3_1,
                short_term_flag_col=short_term_flag_col,
                scra_grade_col=scra_grade_col,
            )
        )
        # PSE guarantors — Art. 116(2) Table 2A for rated, sovereign-derived for unrated.
        .when(gec == "pse")
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _PSE_OWN_RW,
                sovereign_derived_unrated,
            )
        )
        # RGLA guarantors — Art. 115(1)(b) Table 1B for rated, sovereign-derived for unrated.
        .when(gec == "rgla")
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _RGLA_OWN_RW,
                sovereign_derived_unrated,
            )
        )
        # Corporate guarantors — Art. 122 corporate CQS table.
        # Basel 3.1 (PRA PS1/26 Art. 122(2) Table 6): CQS3 = 75% (CRR: 100%);
        # PRA retains CQS5 = 150%. Gated on framework so CRR runs are
        # unchanged.
        .when(gec.is_in(["corporate", "corporate_sme"]))
        .then(build_corporate_guarantor_rw_expr(cqs_col, is_basel_3_1))
        .otherwise(pl.lit(None).cast(pl.Float64))
    )
build_entity_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:298
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("CRR Art. 118")
@cites("CRR Art. 122")
@cites("CRR Art. 123")
def build_entity_rw_expr(
    *,
    entity_type_col: str,
    cqs_col: str,
    is_basel_3_1: bool,
    country_code_col: str | None = None,
) -> pl.Expr:
    """Build the entity-level SA risk-weight preview expression.

    Compiled by the hierarchy facility-share selection
    (``engine/stages/hierarchy/facility_undrawn.py::
    _derive_facility_share_counterparty``) to rank candidate counterparties
    by SA-equivalent risk weight. The preview is non-binding: the chosen
    counterparty still flows through the full classifier and SA/IRB pipeline
    downstream. Keeping the preview SA-only avoids a circular dependency with
    the classifier's IRB approach gating.

    Routes the lowercased ``entity_type`` through the SA exposure-class
    buckets (``ENTITY_TYPES_BY_SA_CLASS``) and maps CQS -> RW via the same
    table branches as :func:`build_guarantor_rw_expr`:

        sovereign (CGCB CQS Table 1, Art. 114)
        International Organisation (Art. 118) -> 0%
        Named MDB (Art. 117(2)) -> 0%
        MDB Table 2B (Art. 117(1))
        Institution (Art. 120 Table 3 / PS1/26 ECRA via
            ``build_institution_guarantor_rw_expr``)
        PSE (Art. 116(2) Table 2A, GB/other approximation for unrated)
        RGLA (Art. 115(1)(b) Table 1B, GB/other approximation for unrated)
        Corporate + covered bond (Art. 122 CRR Table 5 — see note below)
        Retail (Art. 123 flat 75%)
        High risk (Art. 128 flat 150%)
        else -> 1.0 (conservative preview default for unmatched entity
            types, e.g. equity / other items)

    Branch-parity notes (the pre-existing preview branches are preserved
    value-for-value):

    - The corporate branch always prices from ``corporate_risk_weights``
      (CRR Art. 122 Table 5), NOT the Basel 3.1 Table 6 — matching the
      historical preview. Covered bonds use the corporate-equivalent CQS RWs
      in the preview; the precise covered-bond table only applies in real
      SA pricing.
    - The unrated PSE / RGLA fallback is the documented SA-side
      approximation (see :func:`build_guarantor_rw_expr`): GB -> 20%
      domestic-currency treatment, other / unknown country -> 100% unrated
      default. When ``country_code_col`` is ``None`` the 100% default
      applies unconditionally.

    Args:
        entity_type_col: Name of the entity-type column. Null-filled to ""
            and lowercased before bucket routing.
        cqs_col: Name of the integer CQS column; null / out-of-range values
            fall to each table's unrated default.
        is_basel_3_1: Select the PS1/26 institution ECRA table when True,
            CRR Art. 120 Table 3 when False. All other preview branches are
            framework-identical by construction (see corporate note above).
        country_code_col: Optional name of the country-code column driving
            the unrated PSE / RGLA GB-vs-other approximation. ``None`` falls
            back to the conservative 100% unrated default.

    Returns:
        Float64 Polars expression evaluating to the entity's SA-equivalent
        preview risk weight (never null — unmatched entity types yield 1.0).
    """
    et = pl.col(entity_type_col).fill_null("").str.to_lowercase()

    sovereign_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CENTRAL_GOVT_CENTRAL_BANK.value])
    io_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INTERNATIONAL_ORGANISATION.value])
    mdb_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.MDB.value])
    institution_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INSTITUTION.value])
    pse_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.PSE.value])
    rgla_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RGLA.value])
    corporate_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CORPORATE.value])
    covered_bond_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.COVERED_BOND.value])
    retail_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RETAIL_OTHER.value])
    high_risk_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.HIGH_RISK.value])

    unrated_pse_rgla = _pse_rgla_unrated_fallback_expr(country_code_col)

    return (
        # CGCB (Art. 114 Table 1 — sovereign weights).
        pl.when(et.is_in(sovereign_types))
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _CGCB_RW,
                float(_CGCB_RW[CQS.UNRATED]),
            )
        )
        # International Organisation (Art. 118): 0% unconditional.
        .when(et.is_in(io_types))
        .then(pl.lit(float(_IO_ZERO_RW)))
        # Named MDB (Art. 117(2)): 0% unconditional — carved out ahead of Table 2B.
        .when(et == "mdb_named")
        .then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
        # Rated / unrated non-named MDB — Table 2B (Art. 117(1)).
        .when(et.is_in(mdb_types))
        .then(_cqs_table_lookup_expr(cqs_col, _MDB_RW, float(_MDB_UNRATED_RW)))
        # Institution — Art. 120 Table 3 / PS1/26 ECRA via the shared builder
        # so the pack remains the single source of truth.
        .when(et.is_in(institution_types))
        .then(build_institution_guarantor_rw_expr(cqs_col, is_basel_3_1))
        # PSE — Art. 116(2) Table 2A for rated, GB/other approximation for unrated.
        .when(et.is_in(pse_types))
        .then(_cqs_table_lookup_expr(cqs_col, _PSE_OWN_RW, unrated_pse_rgla))
        # RGLA — Art. 115(1)(b) Table 1B for rated, GB/other approximation for unrated.
        .when(et.is_in(rgla_types))
        .then(_cqs_table_lookup_expr(cqs_col, _RGLA_OWN_RW, unrated_pse_rgla))
        # Corporate + covered bond — CRR Art. 122 Table 5 (preview parity:
        # not framework-switched; see docstring).
        .when(et.is_in(corporate_types + covered_bond_types))
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _CORPORATE_RW,
                float(_CORPORATE_RW[CQS.UNRATED]),
            )
        )
        # Retail (Art. 123): flat 75%.
        .when(et.is_in(retail_types))
        .then(pl.lit(_RETAIL_RISK_WEIGHT))
        # High-risk items (Art. 128): flat 150%.
        .when(et.is_in(high_risk_types))
        .then(pl.lit(_HIGH_RISK_RW))
        # Conservative preview default for unmatched entity types.
        .otherwise(pl.lit(1.0))
    )

CRR Art. 117 — Exposures to multilateral development banks

_create_mdb_df — src/rwa_calc/engine/sa/crr_risk_weight_tables.py:315
@cites("CRR Art. 117")
def _create_mdb_df() -> pl.DataFrame:
    """Create MDB risk weight lookup DataFrame (Art. 117(1), Table 2B).

    Named MDBs (Art. 117(2)) get 0% regardless of CQS — handled in the SA calculator.
    Rated non-named MDBs join against this table via their own CQS.
    Unrated non-named MDBs get 50% (Table 2B unrated row).
    """
    return _build_cqs_rw_df(MDB_RISK_WEIGHTS_TABLE_2B, "MDB")
build_guarantor_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:130
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("CRR Art. 118")
@cites("CRR Art. 235")
def build_guarantor_rw_expr(
    *,
    exposure_class_col: str,
    entity_type_col: str,
    cqs_col: str,
    country_code_col: str,
    ccp_client_cleared_col: str,
    scra_grade_col: str,
    is_basel_3_1: bool,
    domestic_cgcb_expr: pl.Expr | None = None,
    short_term_flag_col: str | None = None,
    no_guarantee_expr: pl.Expr | None = None,
) -> pl.Expr:
    """Build the full when/then chain that maps a guarantor to its SA RW.

    Dispatches on the guarantor's SA exposure class (derived from
    ``ENTITY_TYPE_TO_SA_CLASS`` by the caller — e.g. the CRM processor)
    rather than regex on entity_type, ensuring all valid entity types are
    covered. Reproduces the SA-side reference chain
    (``engine/sa/rw_adjustments.py::_build_guarantor_rw_expr``) branch-for-branch.

    Branch order (first match wins):
        no guarantee -> null (only when ``no_guarantee_expr`` is supplied)
        domestic CGCB sovereign (Art. 114(4)/(7)) -> 0%
        CGCB CQS table (Art. 114 Table 1)
        CCP (CRR Art. 306, CRE54.14-15)
        International Organisation (Art. 118) -> 0%
        Named MDB (Art. 117(2)) -> 0%
        MDB Table 2B (Art. 117(1))
        Institution (ECRA / SCRA via build_institution_guarantor_rw_expr —
            short-term Art. 120(2) Table 4 when ``short_term_flag_col``
            evaluates True, otherwise long-term Table 3)
        PSE (Art. 116(2) Table 2A, sovereign-derived for unrated)
        RGLA (Art. 115(1)(b) Table 1B, sovereign-derived for unrated)
        Corporate (Art. 122 corporate CQS table)
        else -> null (no substitution)

    The unrated PSE / RGLA fallback is the documented SA-side approximation
    (no guarantor sovereign-CQS join exists in the CRM column production):
    a GB guarantor receives the 20% RGLA / PSE domestic-currency treatment,
    any other country the conservative 100% unrated default — NOT the full
    Art. 116(1) Table 2 / Art. 115(1)(a) Table 1A sovereign-derived lookup.

    Args:
        exposure_class_col: Name of the guarantor SA exposure-class column
            (e.g. ``guarantor_exposure_class``).
        entity_type_col: Name of the guarantor entity-type column — used for
            the CCP override and the named-MDB (``mdb_named``) carve-out.
        cqs_col: Name of the integer guarantor CQS column.
        country_code_col: Name of the guarantor country-code column — drives
            the unrated PSE / RGLA GB-vs-other approximation.
        ccp_client_cleared_col: Name of the Boolean client-cleared flag
            column (null -> proprietary 2%).
        scra_grade_col: Name of the guarantor SCRA-grade column threaded
            into ``build_institution_guarantor_rw_expr`` for the B31 unrated
            institution dispatch.
        is_basel_3_1: Select PS1/26 tables (institution ECRA / corporate
            Table 6) when True, CRR tables when False. PSE / RGLA / MDB /
            IO / CCP values are framework-identical.
        domestic_cgcb_expr: Caller-supplied Art. 114(4)/(7) domestic-currency
            test (SA and IRB derive domesticity differently). ``None``
            disables the domestic 0% branch (treated as never-domestic).
        short_term_flag_col: Optional Boolean column routing institution
            guarantors to the Art. 120(2) Table 4 short-term dicts. The IRB
            chain passes ``None`` today.
        no_guarantee_expr: Caller-owned leading guard — rows where it
            evaluates True yield null (no substitution priced). ``None``
            omits the guard (the chain prices every row).

    Returns:
        Float64 Polars expression evaluating to the guarantor's SA RW, or
        null where no substitution treatment exists.
    """
    gec = pl.col(exposure_class_col).fill_null("")
    # PSE/RGLA Art. 116(2)/115(1)(b) unrated fallback: domestic-GB guarantors
    # get the RGLA/PSE 20% domestic-currency treatment; otherwise the
    # conservative 100% PSE/RGLA unrated default applies.
    sovereign_derived_unrated = _pse_rgla_unrated_fallback_expr(country_code_col)

    cgcb_unrated = float(_CGCB_RW[CQS.UNRATED])

    is_domestic_guarantor = domestic_cgcb_expr if domestic_cgcb_expr is not None else pl.lit(False)
    skip_substitution = no_guarantee_expr if no_guarantee_expr is not None else pl.lit(False)

    return (
        pl.when(skip_substitution)
        .then(pl.lit(None).cast(pl.Float64))
        # Art. 114(4)/(7): Domestic sovereign -> 0% regardless of CQS.
        .when((gec == "central_govt_central_bank") & is_domestic_guarantor)
        .then(pl.lit(0.0))
        # CGCB guarantors via CQS (Table 1 — sovereign weights).
        .when(gec == "central_govt_central_bank")
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _CGCB_RW,
                cgcb_unrated,
            )
        )
        # CCP guarantors: 2% proprietary / 4% client-cleared
        # (CRR Art. 306, CRE54.14-15) — overrides institution CQS weights.
        .when(pl.col(entity_type_col) == "ccp")
        .then(
            pl.when(pl.col(ccp_client_cleared_col).fill_null(False))
            .then(pl.lit(_QCCP_CLIENT_CLEARED_RW))
            .otherwise(pl.lit(_QCCP_PROPRIETARY_RW))
        )
        # International Organisation (Art. 118): 0% unconditional.
        .when(gec == "international_organisation")
        .then(pl.lit(float(_IO_ZERO_RW)))
        # Named MDB (Art. 117(2)): 0% unconditional.
        .when((gec == "mdb") & (pl.col(entity_type_col).fill_null("") == "mdb_named"))
        .then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
        # Rated / unrated non-named MDB — Table 2B (Art. 117(1)).
        .when(gec == "mdb")
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _MDB_RW,
                float(_MDB_UNRATED_RW),
            )
        )
        # Institution guarantors — RW driven from institution_rw_crr /
        # institution_rw_b31_ecra so the pack remains the single source of
        # truth. When the short-term flag evaluates True (CRR/PS1/26
        # Art. 120(2)), the short-term Table 4 dicts apply instead.
        .when(gec == "institution")
        .then(
            build_institution_guarantor_rw_expr(
                cqs_col,
                is_basel_3_1,
                short_term_flag_col=short_term_flag_col,
                scra_grade_col=scra_grade_col,
            )
        )
        # PSE guarantors — Art. 116(2) Table 2A for rated, sovereign-derived for unrated.
        .when(gec == "pse")
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _PSE_OWN_RW,
                sovereign_derived_unrated,
            )
        )
        # RGLA guarantors — Art. 115(1)(b) Table 1B for rated, sovereign-derived for unrated.
        .when(gec == "rgla")
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _RGLA_OWN_RW,
                sovereign_derived_unrated,
            )
        )
        # Corporate guarantors — Art. 122 corporate CQS table.
        # Basel 3.1 (PRA PS1/26 Art. 122(2) Table 6): CQS3 = 75% (CRR: 100%);
        # PRA retains CQS5 = 150%. Gated on framework so CRR runs are
        # unchanged.
        .when(gec.is_in(["corporate", "corporate_sme"]))
        .then(build_corporate_guarantor_rw_expr(cqs_col, is_basel_3_1))
        .otherwise(pl.lit(None).cast(pl.Float64))
    )
build_entity_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:299
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("CRR Art. 118")
@cites("CRR Art. 122")
@cites("CRR Art. 123")
def build_entity_rw_expr(
    *,
    entity_type_col: str,
    cqs_col: str,
    is_basel_3_1: bool,
    country_code_col: str | None = None,
) -> pl.Expr:
    """Build the entity-level SA risk-weight preview expression.

    Compiled by the hierarchy facility-share selection
    (``engine/stages/hierarchy/facility_undrawn.py::
    _derive_facility_share_counterparty``) to rank candidate counterparties
    by SA-equivalent risk weight. The preview is non-binding: the chosen
    counterparty still flows through the full classifier and SA/IRB pipeline
    downstream. Keeping the preview SA-only avoids a circular dependency with
    the classifier's IRB approach gating.

    Routes the lowercased ``entity_type`` through the SA exposure-class
    buckets (``ENTITY_TYPES_BY_SA_CLASS``) and maps CQS -> RW via the same
    table branches as :func:`build_guarantor_rw_expr`:

        sovereign (CGCB CQS Table 1, Art. 114)
        International Organisation (Art. 118) -> 0%
        Named MDB (Art. 117(2)) -> 0%
        MDB Table 2B (Art. 117(1))
        Institution (Art. 120 Table 3 / PS1/26 ECRA via
            ``build_institution_guarantor_rw_expr``)
        PSE (Art. 116(2) Table 2A, GB/other approximation for unrated)
        RGLA (Art. 115(1)(b) Table 1B, GB/other approximation for unrated)
        Corporate + covered bond (Art. 122 CRR Table 5 — see note below)
        Retail (Art. 123 flat 75%)
        High risk (Art. 128 flat 150%)
        else -> 1.0 (conservative preview default for unmatched entity
            types, e.g. equity / other items)

    Branch-parity notes (the pre-existing preview branches are preserved
    value-for-value):

    - The corporate branch always prices from ``corporate_risk_weights``
      (CRR Art. 122 Table 5), NOT the Basel 3.1 Table 6 — matching the
      historical preview. Covered bonds use the corporate-equivalent CQS RWs
      in the preview; the precise covered-bond table only applies in real
      SA pricing.
    - The unrated PSE / RGLA fallback is the documented SA-side
      approximation (see :func:`build_guarantor_rw_expr`): GB -> 20%
      domestic-currency treatment, other / unknown country -> 100% unrated
      default. When ``country_code_col`` is ``None`` the 100% default
      applies unconditionally.

    Args:
        entity_type_col: Name of the entity-type column. Null-filled to ""
            and lowercased before bucket routing.
        cqs_col: Name of the integer CQS column; null / out-of-range values
            fall to each table's unrated default.
        is_basel_3_1: Select the PS1/26 institution ECRA table when True,
            CRR Art. 120 Table 3 when False. All other preview branches are
            framework-identical by construction (see corporate note above).
        country_code_col: Optional name of the country-code column driving
            the unrated PSE / RGLA GB-vs-other approximation. ``None`` falls
            back to the conservative 100% unrated default.

    Returns:
        Float64 Polars expression evaluating to the entity's SA-equivalent
        preview risk weight (never null — unmatched entity types yield 1.0).
    """
    et = pl.col(entity_type_col).fill_null("").str.to_lowercase()

    sovereign_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CENTRAL_GOVT_CENTRAL_BANK.value])
    io_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INTERNATIONAL_ORGANISATION.value])
    mdb_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.MDB.value])
    institution_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INSTITUTION.value])
    pse_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.PSE.value])
    rgla_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RGLA.value])
    corporate_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CORPORATE.value])
    covered_bond_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.COVERED_BOND.value])
    retail_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RETAIL_OTHER.value])
    high_risk_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.HIGH_RISK.value])

    unrated_pse_rgla = _pse_rgla_unrated_fallback_expr(country_code_col)

    return (
        # CGCB (Art. 114 Table 1 — sovereign weights).
        pl.when(et.is_in(sovereign_types))
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _CGCB_RW,
                float(_CGCB_RW[CQS.UNRATED]),
            )
        )
        # International Organisation (Art. 118): 0% unconditional.
        .when(et.is_in(io_types))
        .then(pl.lit(float(_IO_ZERO_RW)))
        # Named MDB (Art. 117(2)): 0% unconditional — carved out ahead of Table 2B.
        .when(et == "mdb_named")
        .then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
        # Rated / unrated non-named MDB — Table 2B (Art. 117(1)).
        .when(et.is_in(mdb_types))
        .then(_cqs_table_lookup_expr(cqs_col, _MDB_RW, float(_MDB_UNRATED_RW)))
        # Institution — Art. 120 Table 3 / PS1/26 ECRA via the shared builder
        # so the pack remains the single source of truth.
        .when(et.is_in(institution_types))
        .then(build_institution_guarantor_rw_expr(cqs_col, is_basel_3_1))
        # PSE — Art. 116(2) Table 2A for rated, GB/other approximation for unrated.
        .when(et.is_in(pse_types))
        .then(_cqs_table_lookup_expr(cqs_col, _PSE_OWN_RW, unrated_pse_rgla))
        # RGLA — Art. 115(1)(b) Table 1B for rated, GB/other approximation for unrated.
        .when(et.is_in(rgla_types))
        .then(_cqs_table_lookup_expr(cqs_col, _RGLA_OWN_RW, unrated_pse_rgla))
        # Corporate + covered bond — CRR Art. 122 Table 5 (preview parity:
        # not framework-switched; see docstring).
        .when(et.is_in(corporate_types + covered_bond_types))
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _CORPORATE_RW,
                float(_CORPORATE_RW[CQS.UNRATED]),
            )
        )
        # Retail (Art. 123): flat 75%.
        .when(et.is_in(retail_types))
        .then(pl.lit(_RETAIL_RISK_WEIGHT))
        # High-risk items (Art. 128): flat 150%.
        .when(et.is_in(high_risk_types))
        .then(pl.lit(_HIGH_RISK_RW))
        # Conservative preview default for unmatched entity types.
        .otherwise(pl.lit(1.0))
    )

CRR Art. 118 — Exposures to international organisations

_create_io_df — src/rwa_calc/engine/sa/crr_risk_weight_tables.py:339
@cites("CRR Art. 118")
def _create_io_df() -> pl.DataFrame:
    """Create international organisation risk weight lookup DataFrame (Art. 118).

    Art. 118 names 16 IOs (EU, IMF, BIS, ECB, EFSF, ESM, IBRD, IFC, IADB,
    ADB, AfDB, CEB, NIB, CDB, EBRD, EFSI) that receive 0% unconditionally.
    Returns a single-row DataFrame keyed on the unrated CQS sentinel so the
    canonical risk-weight value lives alongside the other SA tables; the
    SA calculator's inline IO branch is the runtime consumer.
    """
    return _build_cqs_rw_df(
        INTERNATIONAL_ORG_RISK_WEIGHTS,
        "INTERNATIONAL_ORG",
        order=(CQS.UNRATED,),
    )
build_guarantor_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:131
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("CRR Art. 118")
@cites("CRR Art. 235")
def build_guarantor_rw_expr(
    *,
    exposure_class_col: str,
    entity_type_col: str,
    cqs_col: str,
    country_code_col: str,
    ccp_client_cleared_col: str,
    scra_grade_col: str,
    is_basel_3_1: bool,
    domestic_cgcb_expr: pl.Expr | None = None,
    short_term_flag_col: str | None = None,
    no_guarantee_expr: pl.Expr | None = None,
) -> pl.Expr:
    """Build the full when/then chain that maps a guarantor to its SA RW.

    Dispatches on the guarantor's SA exposure class (derived from
    ``ENTITY_TYPE_TO_SA_CLASS`` by the caller — e.g. the CRM processor)
    rather than regex on entity_type, ensuring all valid entity types are
    covered. Reproduces the SA-side reference chain
    (``engine/sa/rw_adjustments.py::_build_guarantor_rw_expr``) branch-for-branch.

    Branch order (first match wins):
        no guarantee -> null (only when ``no_guarantee_expr`` is supplied)
        domestic CGCB sovereign (Art. 114(4)/(7)) -> 0%
        CGCB CQS table (Art. 114 Table 1)
        CCP (CRR Art. 306, CRE54.14-15)
        International Organisation (Art. 118) -> 0%
        Named MDB (Art. 117(2)) -> 0%
        MDB Table 2B (Art. 117(1))
        Institution (ECRA / SCRA via build_institution_guarantor_rw_expr —
            short-term Art. 120(2) Table 4 when ``short_term_flag_col``
            evaluates True, otherwise long-term Table 3)
        PSE (Art. 116(2) Table 2A, sovereign-derived for unrated)
        RGLA (Art. 115(1)(b) Table 1B, sovereign-derived for unrated)
        Corporate (Art. 122 corporate CQS table)
        else -> null (no substitution)

    The unrated PSE / RGLA fallback is the documented SA-side approximation
    (no guarantor sovereign-CQS join exists in the CRM column production):
    a GB guarantor receives the 20% RGLA / PSE domestic-currency treatment,
    any other country the conservative 100% unrated default — NOT the full
    Art. 116(1) Table 2 / Art. 115(1)(a) Table 1A sovereign-derived lookup.

    Args:
        exposure_class_col: Name of the guarantor SA exposure-class column
            (e.g. ``guarantor_exposure_class``).
        entity_type_col: Name of the guarantor entity-type column — used for
            the CCP override and the named-MDB (``mdb_named``) carve-out.
        cqs_col: Name of the integer guarantor CQS column.
        country_code_col: Name of the guarantor country-code column — drives
            the unrated PSE / RGLA GB-vs-other approximation.
        ccp_client_cleared_col: Name of the Boolean client-cleared flag
            column (null -> proprietary 2%).
        scra_grade_col: Name of the guarantor SCRA-grade column threaded
            into ``build_institution_guarantor_rw_expr`` for the B31 unrated
            institution dispatch.
        is_basel_3_1: Select PS1/26 tables (institution ECRA / corporate
            Table 6) when True, CRR tables when False. PSE / RGLA / MDB /
            IO / CCP values are framework-identical.
        domestic_cgcb_expr: Caller-supplied Art. 114(4)/(7) domestic-currency
            test (SA and IRB derive domesticity differently). ``None``
            disables the domestic 0% branch (treated as never-domestic).
        short_term_flag_col: Optional Boolean column routing institution
            guarantors to the Art. 120(2) Table 4 short-term dicts. The IRB
            chain passes ``None`` today.
        no_guarantee_expr: Caller-owned leading guard — rows where it
            evaluates True yield null (no substitution priced). ``None``
            omits the guard (the chain prices every row).

    Returns:
        Float64 Polars expression evaluating to the guarantor's SA RW, or
        null where no substitution treatment exists.
    """
    gec = pl.col(exposure_class_col).fill_null("")
    # PSE/RGLA Art. 116(2)/115(1)(b) unrated fallback: domestic-GB guarantors
    # get the RGLA/PSE 20% domestic-currency treatment; otherwise the
    # conservative 100% PSE/RGLA unrated default applies.
    sovereign_derived_unrated = _pse_rgla_unrated_fallback_expr(country_code_col)

    cgcb_unrated = float(_CGCB_RW[CQS.UNRATED])

    is_domestic_guarantor = domestic_cgcb_expr if domestic_cgcb_expr is not None else pl.lit(False)
    skip_substitution = no_guarantee_expr if no_guarantee_expr is not None else pl.lit(False)

    return (
        pl.when(skip_substitution)
        .then(pl.lit(None).cast(pl.Float64))
        # Art. 114(4)/(7): Domestic sovereign -> 0% regardless of CQS.
        .when((gec == "central_govt_central_bank") & is_domestic_guarantor)
        .then(pl.lit(0.0))
        # CGCB guarantors via CQS (Table 1 — sovereign weights).
        .when(gec == "central_govt_central_bank")
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _CGCB_RW,
                cgcb_unrated,
            )
        )
        # CCP guarantors: 2% proprietary / 4% client-cleared
        # (CRR Art. 306, CRE54.14-15) — overrides institution CQS weights.
        .when(pl.col(entity_type_col) == "ccp")
        .then(
            pl.when(pl.col(ccp_client_cleared_col).fill_null(False))
            .then(pl.lit(_QCCP_CLIENT_CLEARED_RW))
            .otherwise(pl.lit(_QCCP_PROPRIETARY_RW))
        )
        # International Organisation (Art. 118): 0% unconditional.
        .when(gec == "international_organisation")
        .then(pl.lit(float(_IO_ZERO_RW)))
        # Named MDB (Art. 117(2)): 0% unconditional.
        .when((gec == "mdb") & (pl.col(entity_type_col).fill_null("") == "mdb_named"))
        .then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
        # Rated / unrated non-named MDB — Table 2B (Art. 117(1)).
        .when(gec == "mdb")
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _MDB_RW,
                float(_MDB_UNRATED_RW),
            )
        )
        # Institution guarantors — RW driven from institution_rw_crr /
        # institution_rw_b31_ecra so the pack remains the single source of
        # truth. When the short-term flag evaluates True (CRR/PS1/26
        # Art. 120(2)), the short-term Table 4 dicts apply instead.
        .when(gec == "institution")
        .then(
            build_institution_guarantor_rw_expr(
                cqs_col,
                is_basel_3_1,
                short_term_flag_col=short_term_flag_col,
                scra_grade_col=scra_grade_col,
            )
        )
        # PSE guarantors — Art. 116(2) Table 2A for rated, sovereign-derived for unrated.
        .when(gec == "pse")
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _PSE_OWN_RW,
                sovereign_derived_unrated,
            )
        )
        # RGLA guarantors — Art. 115(1)(b) Table 1B for rated, sovereign-derived for unrated.
        .when(gec == "rgla")
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _RGLA_OWN_RW,
                sovereign_derived_unrated,
            )
        )
        # Corporate guarantors — Art. 122 corporate CQS table.
        # Basel 3.1 (PRA PS1/26 Art. 122(2) Table 6): CQS3 = 75% (CRR: 100%);
        # PRA retains CQS5 = 150%. Gated on framework so CRR runs are
        # unchanged.
        .when(gec.is_in(["corporate", "corporate_sme"]))
        .then(build_corporate_guarantor_rw_expr(cqs_col, is_basel_3_1))
        .otherwise(pl.lit(None).cast(pl.Float64))
    )
build_entity_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:300
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("CRR Art. 118")
@cites("CRR Art. 122")
@cites("CRR Art. 123")
def build_entity_rw_expr(
    *,
    entity_type_col: str,
    cqs_col: str,
    is_basel_3_1: bool,
    country_code_col: str | None = None,
) -> pl.Expr:
    """Build the entity-level SA risk-weight preview expression.

    Compiled by the hierarchy facility-share selection
    (``engine/stages/hierarchy/facility_undrawn.py::
    _derive_facility_share_counterparty``) to rank candidate counterparties
    by SA-equivalent risk weight. The preview is non-binding: the chosen
    counterparty still flows through the full classifier and SA/IRB pipeline
    downstream. Keeping the preview SA-only avoids a circular dependency with
    the classifier's IRB approach gating.

    Routes the lowercased ``entity_type`` through the SA exposure-class
    buckets (``ENTITY_TYPES_BY_SA_CLASS``) and maps CQS -> RW via the same
    table branches as :func:`build_guarantor_rw_expr`:

        sovereign (CGCB CQS Table 1, Art. 114)
        International Organisation (Art. 118) -> 0%
        Named MDB (Art. 117(2)) -> 0%
        MDB Table 2B (Art. 117(1))
        Institution (Art. 120 Table 3 / PS1/26 ECRA via
            ``build_institution_guarantor_rw_expr``)
        PSE (Art. 116(2) Table 2A, GB/other approximation for unrated)
        RGLA (Art. 115(1)(b) Table 1B, GB/other approximation for unrated)
        Corporate + covered bond (Art. 122 CRR Table 5 — see note below)
        Retail (Art. 123 flat 75%)
        High risk (Art. 128 flat 150%)
        else -> 1.0 (conservative preview default for unmatched entity
            types, e.g. equity / other items)

    Branch-parity notes (the pre-existing preview branches are preserved
    value-for-value):

    - The corporate branch always prices from ``corporate_risk_weights``
      (CRR Art. 122 Table 5), NOT the Basel 3.1 Table 6 — matching the
      historical preview. Covered bonds use the corporate-equivalent CQS RWs
      in the preview; the precise covered-bond table only applies in real
      SA pricing.
    - The unrated PSE / RGLA fallback is the documented SA-side
      approximation (see :func:`build_guarantor_rw_expr`): GB -> 20%
      domestic-currency treatment, other / unknown country -> 100% unrated
      default. When ``country_code_col`` is ``None`` the 100% default
      applies unconditionally.

    Args:
        entity_type_col: Name of the entity-type column. Null-filled to ""
            and lowercased before bucket routing.
        cqs_col: Name of the integer CQS column; null / out-of-range values
            fall to each table's unrated default.
        is_basel_3_1: Select the PS1/26 institution ECRA table when True,
            CRR Art. 120 Table 3 when False. All other preview branches are
            framework-identical by construction (see corporate note above).
        country_code_col: Optional name of the country-code column driving
            the unrated PSE / RGLA GB-vs-other approximation. ``None`` falls
            back to the conservative 100% unrated default.

    Returns:
        Float64 Polars expression evaluating to the entity's SA-equivalent
        preview risk weight (never null — unmatched entity types yield 1.0).
    """
    et = pl.col(entity_type_col).fill_null("").str.to_lowercase()

    sovereign_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CENTRAL_GOVT_CENTRAL_BANK.value])
    io_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INTERNATIONAL_ORGANISATION.value])
    mdb_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.MDB.value])
    institution_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INSTITUTION.value])
    pse_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.PSE.value])
    rgla_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RGLA.value])
    corporate_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CORPORATE.value])
    covered_bond_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.COVERED_BOND.value])
    retail_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RETAIL_OTHER.value])
    high_risk_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.HIGH_RISK.value])

    unrated_pse_rgla = _pse_rgla_unrated_fallback_expr(country_code_col)

    return (
        # CGCB (Art. 114 Table 1 — sovereign weights).
        pl.when(et.is_in(sovereign_types))
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _CGCB_RW,
                float(_CGCB_RW[CQS.UNRATED]),
            )
        )
        # International Organisation (Art. 118): 0% unconditional.
        .when(et.is_in(io_types))
        .then(pl.lit(float(_IO_ZERO_RW)))
        # Named MDB (Art. 117(2)): 0% unconditional — carved out ahead of Table 2B.
        .when(et == "mdb_named")
        .then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
        # Rated / unrated non-named MDB — Table 2B (Art. 117(1)).
        .when(et.is_in(mdb_types))
        .then(_cqs_table_lookup_expr(cqs_col, _MDB_RW, float(_MDB_UNRATED_RW)))
        # Institution — Art. 120 Table 3 / PS1/26 ECRA via the shared builder
        # so the pack remains the single source of truth.
        .when(et.is_in(institution_types))
        .then(build_institution_guarantor_rw_expr(cqs_col, is_basel_3_1))
        # PSE — Art. 116(2) Table 2A for rated, GB/other approximation for unrated.
        .when(et.is_in(pse_types))
        .then(_cqs_table_lookup_expr(cqs_col, _PSE_OWN_RW, unrated_pse_rgla))
        # RGLA — Art. 115(1)(b) Table 1B for rated, GB/other approximation for unrated.
        .when(et.is_in(rgla_types))
        .then(_cqs_table_lookup_expr(cqs_col, _RGLA_OWN_RW, unrated_pse_rgla))
        # Corporate + covered bond — CRR Art. 122 Table 5 (preview parity:
        # not framework-switched; see docstring).
        .when(et.is_in(corporate_types + covered_bond_types))
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _CORPORATE_RW,
                float(_CORPORATE_RW[CQS.UNRATED]),
            )
        )
        # Retail (Art. 123): flat 75%.
        .when(et.is_in(retail_types))
        .then(pl.lit(_RETAIL_RISK_WEIGHT))
        # High-risk items (Art. 128): flat 150%.
        .when(et.is_in(high_risk_types))
        .then(pl.lit(_HIGH_RISK_RW))
        # Conservative preview default for unmatched entity types.
        .otherwise(pl.lit(1.0))
    )

CRR Art. 119 — Exposures to institutions

build_institution_guarantor_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:433
@cites("CRR Art. 119")
@cites("CRR Art. 120")
@cites("CRR Art. 121")
def build_institution_guarantor_rw_expr(
    cqs_col: str,
    is_basel_3_1: bool,
    short_term_flag_col: str | None = None,
    scra_grade_col: str | None = None,
) -> pl.Expr:
    """Build a CQS → institution risk weight expression from the canonical tables.

    Used by SA and IRB guarantee substitution to look up the RW to apply to the
    guaranteed portion when the guarantor is an institution. Drives values from
    ``institution_rw_crr`` / ``institution_rw_b31_ecra`` (long-term, Art. 120
    Table 3) or ``institution_short_term_rw_crr`` /
    ``institution_short_term_rw_b31_ecra`` (short-term, Art. 120(2) Table 4) so
    there is a single source of truth.

    Args:
        cqs_col: Name of the integer CQS column on the frame.
        is_basel_3_1: Select PS1/26 ECRA table when True, CRR Art. 120 Table 3
            when False.
        short_term_flag_col: Optional name of a Boolean column. When provided,
            rows where the column evaluates True route to the Art. 120(2)
            Table 4 short-term dict (residual maturity ≤ 3 months); rows where
            the column is False or null use the long-term Table 3 dict.
        scra_grade_col: Optional name of a Utf8 column carrying the guarantor's
            SCRA grade ("A" / "A_ENHANCED" / "B" / "C"). When provided AND
            ``is_basel_3_1`` is True, rows whose CQS column is null (i.e.
            unrated under ECRA) dispatch via PRA PS1/26 Art. 121 Table 5 SCRA
            grades using ``b31_scra_risk_weights`` (long-term) or
            ``b31_scra_short_term_risk_weights`` (short-term branch when the
            ``short_term_flag_col`` evaluates True). A null/missing SCRA grade
            falls back to ``b31_scra_risk_weights["C"]`` per CRE20.21
            conservative-fallback. The CRR path and the rated B31 path
            (CQS 1-6) are entirely unaffected.

    Returns:
        Float64 Polars expression evaluating to the institution RW.
    """
    long_term = _INSTITUTION_RW_B31_ECRA if is_basel_3_1 else _INSTITUTION_RW_CRR
    short_term = (
        _INSTITUTION_SHORT_TERM_RW_B31_ECRA if is_basel_3_1 else _INSTITUTION_SHORT_TERM_RW_CRR
    )
    col = pl.col(cqs_col)
    use_scra = is_basel_3_1 and scra_grade_col is not None

    def _scra_branch(table: dict[str, Decimal]) -> pl.Expr:
        scra = pl.col(cast("str", scra_grade_col))
        # CRE20.21 conservative fallback: null/missing SCRA grade -> Grade C.
        return (
            pl.when(scra == "A_ENHANCED")
            .then(pl.lit(float(table["A_ENHANCED"])))
            .when(scra == "A")
            .then(pl.lit(float(table["A"])))
            .when(scra == "B")
            .then(pl.lit(float(table["B"])))
            .otherwise(pl.lit(float(table["C"])))
        )

    def _branch(table: dict[CQS, Decimal], scra_table: dict[str, Decimal]) -> pl.Expr:
        rated = (
            pl.when(col == 1)
            .then(pl.lit(float(table[CQS.CQS1])))
            .when(col == 2)
            .then(pl.lit(float(table[CQS.CQS2])))
            .when(col == 3)
            .then(pl.lit(float(table[CQS.CQS3])))
            .when(col.is_in([4, 5]))
            .then(pl.lit(float(table[CQS.CQS4])))
            .when(col == 6)
            .then(pl.lit(float(table[CQS.CQS6])))
            .otherwise(pl.lit(float(table[CQS.UNRATED])))
        )
        if not use_scra:
            return rated
        # B31 + SCRA available: route unrated (null CQS) rows via SCRA grades.
        return pl.when(col.is_null()).then(_scra_branch(scra_table)).otherwise(rated)

    long_branch = _branch(long_term, _B31_SCRA_RW)
    short_branch = _branch(short_term, _B31_SCRA_SHORT_TERM_RW)

    if short_term_flag_col is None:
        return long_branch

    is_short_term = pl.col(short_term_flag_col).fill_null(False)
    return pl.when(is_short_term).then(short_branch).otherwise(long_branch)

CRR Art. 120 — Exposures to rated institutions

build_institution_guarantor_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:434
@cites("CRR Art. 119")
@cites("CRR Art. 120")
@cites("CRR Art. 121")
def build_institution_guarantor_rw_expr(
    cqs_col: str,
    is_basel_3_1: bool,
    short_term_flag_col: str | None = None,
    scra_grade_col: str | None = None,
) -> pl.Expr:
    """Build a CQS → institution risk weight expression from the canonical tables.

    Used by SA and IRB guarantee substitution to look up the RW to apply to the
    guaranteed portion when the guarantor is an institution. Drives values from
    ``institution_rw_crr`` / ``institution_rw_b31_ecra`` (long-term, Art. 120
    Table 3) or ``institution_short_term_rw_crr`` /
    ``institution_short_term_rw_b31_ecra`` (short-term, Art. 120(2) Table 4) so
    there is a single source of truth.

    Args:
        cqs_col: Name of the integer CQS column on the frame.
        is_basel_3_1: Select PS1/26 ECRA table when True, CRR Art. 120 Table 3
            when False.
        short_term_flag_col: Optional name of a Boolean column. When provided,
            rows where the column evaluates True route to the Art. 120(2)
            Table 4 short-term dict (residual maturity ≤ 3 months); rows where
            the column is False or null use the long-term Table 3 dict.
        scra_grade_col: Optional name of a Utf8 column carrying the guarantor's
            SCRA grade ("A" / "A_ENHANCED" / "B" / "C"). When provided AND
            ``is_basel_3_1`` is True, rows whose CQS column is null (i.e.
            unrated under ECRA) dispatch via PRA PS1/26 Art. 121 Table 5 SCRA
            grades using ``b31_scra_risk_weights`` (long-term) or
            ``b31_scra_short_term_risk_weights`` (short-term branch when the
            ``short_term_flag_col`` evaluates True). A null/missing SCRA grade
            falls back to ``b31_scra_risk_weights["C"]`` per CRE20.21
            conservative-fallback. The CRR path and the rated B31 path
            (CQS 1-6) are entirely unaffected.

    Returns:
        Float64 Polars expression evaluating to the institution RW.
    """
    long_term = _INSTITUTION_RW_B31_ECRA if is_basel_3_1 else _INSTITUTION_RW_CRR
    short_term = (
        _INSTITUTION_SHORT_TERM_RW_B31_ECRA if is_basel_3_1 else _INSTITUTION_SHORT_TERM_RW_CRR
    )
    col = pl.col(cqs_col)
    use_scra = is_basel_3_1 and scra_grade_col is not None

    def _scra_branch(table: dict[str, Decimal]) -> pl.Expr:
        scra = pl.col(cast("str", scra_grade_col))
        # CRE20.21 conservative fallback: null/missing SCRA grade -> Grade C.
        return (
            pl.when(scra == "A_ENHANCED")
            .then(pl.lit(float(table["A_ENHANCED"])))
            .when(scra == "A")
            .then(pl.lit(float(table["A"])))
            .when(scra == "B")
            .then(pl.lit(float(table["B"])))
            .otherwise(pl.lit(float(table["C"])))
        )

    def _branch(table: dict[CQS, Decimal], scra_table: dict[str, Decimal]) -> pl.Expr:
        rated = (
            pl.when(col == 1)
            .then(pl.lit(float(table[CQS.CQS1])))
            .when(col == 2)
            .then(pl.lit(float(table[CQS.CQS2])))
            .when(col == 3)
            .then(pl.lit(float(table[CQS.CQS3])))
            .when(col.is_in([4, 5]))
            .then(pl.lit(float(table[CQS.CQS4])))
            .when(col == 6)
            .then(pl.lit(float(table[CQS.CQS6])))
            .otherwise(pl.lit(float(table[CQS.UNRATED])))
        )
        if not use_scra:
            return rated
        # B31 + SCRA available: route unrated (null CQS) rows via SCRA grades.
        return pl.when(col.is_null()).then(_scra_branch(scra_table)).otherwise(rated)

    long_branch = _branch(long_term, _B31_SCRA_RW)
    short_branch = _branch(short_term, _B31_SCRA_SHORT_TERM_RW)

    if short_term_flag_col is None:
        return long_branch

    is_short_term = pl.col(short_term_flag_col).fill_null(False)
    return pl.when(is_short_term).then(short_branch).otherwise(long_branch)

CRR Art. 121 — Exposures to unrated institutions

build_institution_guarantor_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:435
@cites("CRR Art. 119")
@cites("CRR Art. 120")
@cites("CRR Art. 121")
def build_institution_guarantor_rw_expr(
    cqs_col: str,
    is_basel_3_1: bool,
    short_term_flag_col: str | None = None,
    scra_grade_col: str | None = None,
) -> pl.Expr:
    """Build a CQS → institution risk weight expression from the canonical tables.

    Used by SA and IRB guarantee substitution to look up the RW to apply to the
    guaranteed portion when the guarantor is an institution. Drives values from
    ``institution_rw_crr`` / ``institution_rw_b31_ecra`` (long-term, Art. 120
    Table 3) or ``institution_short_term_rw_crr`` /
    ``institution_short_term_rw_b31_ecra`` (short-term, Art. 120(2) Table 4) so
    there is a single source of truth.

    Args:
        cqs_col: Name of the integer CQS column on the frame.
        is_basel_3_1: Select PS1/26 ECRA table when True, CRR Art. 120 Table 3
            when False.
        short_term_flag_col: Optional name of a Boolean column. When provided,
            rows where the column evaluates True route to the Art. 120(2)
            Table 4 short-term dict (residual maturity ≤ 3 months); rows where
            the column is False or null use the long-term Table 3 dict.
        scra_grade_col: Optional name of a Utf8 column carrying the guarantor's
            SCRA grade ("A" / "A_ENHANCED" / "B" / "C"). When provided AND
            ``is_basel_3_1`` is True, rows whose CQS column is null (i.e.
            unrated under ECRA) dispatch via PRA PS1/26 Art. 121 Table 5 SCRA
            grades using ``b31_scra_risk_weights`` (long-term) or
            ``b31_scra_short_term_risk_weights`` (short-term branch when the
            ``short_term_flag_col`` evaluates True). A null/missing SCRA grade
            falls back to ``b31_scra_risk_weights["C"]`` per CRE20.21
            conservative-fallback. The CRR path and the rated B31 path
            (CQS 1-6) are entirely unaffected.

    Returns:
        Float64 Polars expression evaluating to the institution RW.
    """
    long_term = _INSTITUTION_RW_B31_ECRA if is_basel_3_1 else _INSTITUTION_RW_CRR
    short_term = (
        _INSTITUTION_SHORT_TERM_RW_B31_ECRA if is_basel_3_1 else _INSTITUTION_SHORT_TERM_RW_CRR
    )
    col = pl.col(cqs_col)
    use_scra = is_basel_3_1 and scra_grade_col is not None

    def _scra_branch(table: dict[str, Decimal]) -> pl.Expr:
        scra = pl.col(cast("str", scra_grade_col))
        # CRE20.21 conservative fallback: null/missing SCRA grade -> Grade C.
        return (
            pl.when(scra == "A_ENHANCED")
            .then(pl.lit(float(table["A_ENHANCED"])))
            .when(scra == "A")
            .then(pl.lit(float(table["A"])))
            .when(scra == "B")
            .then(pl.lit(float(table["B"])))
            .otherwise(pl.lit(float(table["C"])))
        )

    def _branch(table: dict[CQS, Decimal], scra_table: dict[str, Decimal]) -> pl.Expr:
        rated = (
            pl.when(col == 1)
            .then(pl.lit(float(table[CQS.CQS1])))
            .when(col == 2)
            .then(pl.lit(float(table[CQS.CQS2])))
            .when(col == 3)
            .then(pl.lit(float(table[CQS.CQS3])))
            .when(col.is_in([4, 5]))
            .then(pl.lit(float(table[CQS.CQS4])))
            .when(col == 6)
            .then(pl.lit(float(table[CQS.CQS6])))
            .otherwise(pl.lit(float(table[CQS.UNRATED])))
        )
        if not use_scra:
            return rated
        # B31 + SCRA available: route unrated (null CQS) rows via SCRA grades.
        return pl.when(col.is_null()).then(_scra_branch(scra_table)).otherwise(rated)

    long_branch = _branch(long_term, _B31_SCRA_RW)
    short_branch = _branch(short_term, _B31_SCRA_SHORT_TERM_RW)

    if short_term_flag_col is None:
        return long_branch

    is_short_term = pl.col(short_term_flag_col).fill_null(False)
    return pl.when(is_short_term).then(short_branch).otherwise(long_branch)

CRR Art. 122 — Exposures to corporates

_compute_guarantor_rw_sa — src/rwa_calc/engine/irb/guarantee.py:200
@cites("CRR Art. 122")
@cites("CRR Art. 235")
def _compute_guarantor_rw_sa(
    lf: pl.LazyFrame,
    cols: list[str],
    config: CalculationConfig,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """Compute the guarantor's SA risk weight via the shared builder.

    Compiles ``build_guarantor_rw_expr`` (data/tables/guarantor_rw.py) with
    the IRB chain's column names — the same branch chain and order as the
    SA-side twin (engine/sa/namespace.py::_build_guarantor_rw_expr). This
    closes the IRB-guarantor PSE / RGLA substitution gap (the recorded
    Phase 4 fix) plus the IO 0%, named-MDB 0% and MDB Table 2B closures.
    """

    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack

    # Ensure guarantor_exposure_class is available (set by CRM processor;
    # fallback for unit tests that construct LazyFrames directly)
    if "guarantor_exposure_class" not in cols:
        from rwa_calc.engine.entity_class_maps import ENTITY_TYPE_TO_SA_CLASS

        lf = lf.with_columns(
            pl.col("guarantor_entity_type")
            .fill_null("")
            .replace_strict(ENTITY_TYPE_TO_SA_CLASS, default="")
            .alias("guarantor_exposure_class"),
        )
    if "guarantor_is_ccp_client_cleared" not in cols:
        lf = lf.with_columns(
            pl.lit(None).cast(pl.Boolean).alias("guarantor_is_ccp_client_cleared"),
        )
    # B31 SCRA dispatch fallback: ensure ``guarantor_scra_grade`` is referenceable
    # by ``build_institution_guarantor_rw_expr``. The CRM processor populates this
    # column from counterparties.scra_grade (engine/crm/guarantees.py); fall back
    # to null for unit tests that construct LazyFrames directly without going
    # through the CRM join.
    if "guarantor_scra_grade" not in cols:
        lf = lf.with_columns(
            pl.lit(None).cast(pl.String).alias("guarantor_scra_grade"),
        )

    _gec = pl.col("guarantor_exposure_class").fill_null("")

    # Art. 114(4)/(7): Domestic CGCB guarantors -> 0% RW regardless of CQS.
    # Evaluate the domestic-currency test against the guarantee currency (the
    # currency of the substituted exposure to the sovereign); the Art. 233(3) 8%
    # FX haircut separately handles any mismatch between the guarantee and the
    # underlying exposure. Fall back to the exposure's pre-FX denomination when
    # `guarantee_currency` is missing (legacy / no-guarantee rows).
    _irb_schema_names = lf.collect_schema().names()
    _has_country = "guarantor_country_code" in _irb_schema_names
    _has_exposure_ccy_irb = (
        "original_currency" in _irb_schema_names or "currency" in _irb_schema_names
    )
    _has_guarantee_ccy_irb = "guarantee_currency" in _irb_schema_names
    if _has_guarantee_ccy_irb and _has_exposure_ccy_irb:
        _ccy_expr_irb = pl.col("guarantee_currency").fill_null(
            denomination_currency_expr(_irb_schema_names)
        )
    elif _has_guarantee_ccy_irb:
        _ccy_expr_irb = pl.col("guarantee_currency")
    elif _has_exposure_ccy_irb:
        _ccy_expr_irb = denomination_currency_expr(_irb_schema_names)
    else:
        _ccy_expr_irb = None

    _is_domestic_guarantor = (
        build_domestic_cgcb_guarantor_expr("guarantor_country_code", _ccy_expr_irb)
        if _has_country and _ccy_expr_irb is not None
        else pl.lit(False)
    )

    # The shared expression's unrated PSE/RGLA fallback reads the guarantor
    # country column; ensure it is referenceable for direct (non-pipeline)
    # invocation, mirroring the ccp / scra fallbacks above. The pipeline
    # always carries it (joined by engine/crm/guarantees.py).
    if not _has_country:
        lf = lf.with_columns(
            pl.lit(None).cast(pl.String).alias("guarantor_country_code"),
        )

    return lf.with_columns(
        build_guarantor_rw_expr(
            exposure_class_col="guarantor_exposure_class",
            entity_type_col="guarantor_entity_type",
            cqs_col="guarantor_cqs",
            country_code_col="guarantor_country_code",
            ccp_client_cleared_col="guarantor_is_ccp_client_cleared",
            scra_grade_col="guarantor_scra_grade",
            is_basel_3_1=resolved_pack.feature("sa_revised_risk_weight_tables"),
            domestic_cgcb_expr=_is_domestic_guarantor,
            # No borrower-maturity short-term flag is threaded on the IRB
            # path today (the SA twin derives one from its own stage
            # scratch); long-term Table 3 applies throughout.
            short_term_flag_col=None,
            no_guarantee_expr=pl.col("guaranteed_portion").fill_null(0) <= 0,
        ).alias("guarantor_rw_sa"),
    )
build_entity_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:301
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("CRR Art. 118")
@cites("CRR Art. 122")
@cites("CRR Art. 123")
def build_entity_rw_expr(
    *,
    entity_type_col: str,
    cqs_col: str,
    is_basel_3_1: bool,
    country_code_col: str | None = None,
) -> pl.Expr:
    """Build the entity-level SA risk-weight preview expression.

    Compiled by the hierarchy facility-share selection
    (``engine/stages/hierarchy/facility_undrawn.py::
    _derive_facility_share_counterparty``) to rank candidate counterparties
    by SA-equivalent risk weight. The preview is non-binding: the chosen
    counterparty still flows through the full classifier and SA/IRB pipeline
    downstream. Keeping the preview SA-only avoids a circular dependency with
    the classifier's IRB approach gating.

    Routes the lowercased ``entity_type`` through the SA exposure-class
    buckets (``ENTITY_TYPES_BY_SA_CLASS``) and maps CQS -> RW via the same
    table branches as :func:`build_guarantor_rw_expr`:

        sovereign (CGCB CQS Table 1, Art. 114)
        International Organisation (Art. 118) -> 0%
        Named MDB (Art. 117(2)) -> 0%
        MDB Table 2B (Art. 117(1))
        Institution (Art. 120 Table 3 / PS1/26 ECRA via
            ``build_institution_guarantor_rw_expr``)
        PSE (Art. 116(2) Table 2A, GB/other approximation for unrated)
        RGLA (Art. 115(1)(b) Table 1B, GB/other approximation for unrated)
        Corporate + covered bond (Art. 122 CRR Table 5 — see note below)
        Retail (Art. 123 flat 75%)
        High risk (Art. 128 flat 150%)
        else -> 1.0 (conservative preview default for unmatched entity
            types, e.g. equity / other items)

    Branch-parity notes (the pre-existing preview branches are preserved
    value-for-value):

    - The corporate branch always prices from ``corporate_risk_weights``
      (CRR Art. 122 Table 5), NOT the Basel 3.1 Table 6 — matching the
      historical preview. Covered bonds use the corporate-equivalent CQS RWs
      in the preview; the precise covered-bond table only applies in real
      SA pricing.
    - The unrated PSE / RGLA fallback is the documented SA-side
      approximation (see :func:`build_guarantor_rw_expr`): GB -> 20%
      domestic-currency treatment, other / unknown country -> 100% unrated
      default. When ``country_code_col`` is ``None`` the 100% default
      applies unconditionally.

    Args:
        entity_type_col: Name of the entity-type column. Null-filled to ""
            and lowercased before bucket routing.
        cqs_col: Name of the integer CQS column; null / out-of-range values
            fall to each table's unrated default.
        is_basel_3_1: Select the PS1/26 institution ECRA table when True,
            CRR Art. 120 Table 3 when False. All other preview branches are
            framework-identical by construction (see corporate note above).
        country_code_col: Optional name of the country-code column driving
            the unrated PSE / RGLA GB-vs-other approximation. ``None`` falls
            back to the conservative 100% unrated default.

    Returns:
        Float64 Polars expression evaluating to the entity's SA-equivalent
        preview risk weight (never null — unmatched entity types yield 1.0).
    """
    et = pl.col(entity_type_col).fill_null("").str.to_lowercase()

    sovereign_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CENTRAL_GOVT_CENTRAL_BANK.value])
    io_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INTERNATIONAL_ORGANISATION.value])
    mdb_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.MDB.value])
    institution_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INSTITUTION.value])
    pse_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.PSE.value])
    rgla_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RGLA.value])
    corporate_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CORPORATE.value])
    covered_bond_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.COVERED_BOND.value])
    retail_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RETAIL_OTHER.value])
    high_risk_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.HIGH_RISK.value])

    unrated_pse_rgla = _pse_rgla_unrated_fallback_expr(country_code_col)

    return (
        # CGCB (Art. 114 Table 1 — sovereign weights).
        pl.when(et.is_in(sovereign_types))
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _CGCB_RW,
                float(_CGCB_RW[CQS.UNRATED]),
            )
        )
        # International Organisation (Art. 118): 0% unconditional.
        .when(et.is_in(io_types))
        .then(pl.lit(float(_IO_ZERO_RW)))
        # Named MDB (Art. 117(2)): 0% unconditional — carved out ahead of Table 2B.
        .when(et == "mdb_named")
        .then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
        # Rated / unrated non-named MDB — Table 2B (Art. 117(1)).
        .when(et.is_in(mdb_types))
        .then(_cqs_table_lookup_expr(cqs_col, _MDB_RW, float(_MDB_UNRATED_RW)))
        # Institution — Art. 120 Table 3 / PS1/26 ECRA via the shared builder
        # so the pack remains the single source of truth.
        .when(et.is_in(institution_types))
        .then(build_institution_guarantor_rw_expr(cqs_col, is_basel_3_1))
        # PSE — Art. 116(2) Table 2A for rated, GB/other approximation for unrated.
        .when(et.is_in(pse_types))
        .then(_cqs_table_lookup_expr(cqs_col, _PSE_OWN_RW, unrated_pse_rgla))
        # RGLA — Art. 115(1)(b) Table 1B for rated, GB/other approximation for unrated.
        .when(et.is_in(rgla_types))
        .then(_cqs_table_lookup_expr(cqs_col, _RGLA_OWN_RW, unrated_pse_rgla))
        # Corporate + covered bond — CRR Art. 122 Table 5 (preview parity:
        # not framework-switched; see docstring).
        .when(et.is_in(corporate_types + covered_bond_types))
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _CORPORATE_RW,
                float(_CORPORATE_RW[CQS.UNRATED]),
            )
        )
        # Retail (Art. 123): flat 75%.
        .when(et.is_in(retail_types))
        .then(pl.lit(_RETAIL_RISK_WEIGHT))
        # High-risk items (Art. 128): flat 150%.
        .when(et.is_in(high_risk_types))
        .then(pl.lit(_HIGH_RISK_RW))
        # Conservative preview default for unmatched entity types.
        .otherwise(pl.lit(1.0))
    )
build_corporate_guarantor_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:522
@cites("CRR Art. 122")
def build_corporate_guarantor_rw_expr(
    cqs_col: str,
    is_basel_3_1: bool,
) -> pl.Expr:
    """Build a CQS → corporate risk weight expression from the canonical tables.

    Used by SA and IRB guarantee substitution to look up the RW to apply to the
    guaranteed portion when the guarantor is a corporate. Drives values from
    ``corporate_risk_weights`` (CRR Art. 122 Table 5) or
    ``b31_corporate_risk_weights`` (PRA PS1/26 Art. 122(2) Table 6) so there is
    a single source of truth — and B3.1 corporate CQS3 correctly maps to 75%
    (Table 6) instead of CRR Table 5's 100%.

    Args:
        cqs_col: Name of the integer CQS column on the frame.
        is_basel_3_1: Select PS1/26 Art. 122(2) Table 6 when True, CRR Art. 122
            Table 5 when False.

    Returns:
        Float64 Polars expression evaluating to the corporate RW.
    """
    col = pl.col(cqs_col)
    if is_basel_3_1:
        rw_1 = float(_B31_CORPORATE_RW[1])
        rw_2 = float(_B31_CORPORATE_RW[2])
        rw_3 = float(_B31_CORPORATE_RW[3])
        rw_4 = float(_B31_CORPORATE_RW[4])
        rw_5 = float(_B31_CORPORATE_RW[5])
        rw_6 = float(_B31_CORPORATE_RW[6])
        rw_unrated = float(_B31_CORPORATE_RW[None])
    else:
        rw_1 = float(_CORPORATE_RW[CQS.CQS1])
        rw_2 = float(_CORPORATE_RW[CQS.CQS2])
        rw_3 = float(_CORPORATE_RW[CQS.CQS3])
        rw_4 = float(_CORPORATE_RW[CQS.CQS4])
        rw_5 = float(_CORPORATE_RW[CQS.CQS5])
        rw_6 = float(_CORPORATE_RW[CQS.CQS6])
        rw_unrated = float(_CORPORATE_RW[CQS.UNRATED])

    return (
        pl.when(col == 1)
        .then(pl.lit(rw_1))
        .when(col == 2)
        .then(pl.lit(rw_2))
        .when(col == 3)
        .then(pl.lit(rw_3))
        .when(col == 4)
        .then(pl.lit(rw_4))
        .when(col == 5)
        .then(pl.lit(rw_5))
        .when(col == 6)
        .then(pl.lit(rw_6))
        .otherwise(pl.lit(rw_unrated))
    )

CRR Art. 123 — Retail exposures

build_entity_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:302
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("CRR Art. 118")
@cites("CRR Art. 122")
@cites("CRR Art. 123")
def build_entity_rw_expr(
    *,
    entity_type_col: str,
    cqs_col: str,
    is_basel_3_1: bool,
    country_code_col: str | None = None,
) -> pl.Expr:
    """Build the entity-level SA risk-weight preview expression.

    Compiled by the hierarchy facility-share selection
    (``engine/stages/hierarchy/facility_undrawn.py::
    _derive_facility_share_counterparty``) to rank candidate counterparties
    by SA-equivalent risk weight. The preview is non-binding: the chosen
    counterparty still flows through the full classifier and SA/IRB pipeline
    downstream. Keeping the preview SA-only avoids a circular dependency with
    the classifier's IRB approach gating.

    Routes the lowercased ``entity_type`` through the SA exposure-class
    buckets (``ENTITY_TYPES_BY_SA_CLASS``) and maps CQS -> RW via the same
    table branches as :func:`build_guarantor_rw_expr`:

        sovereign (CGCB CQS Table 1, Art. 114)
        International Organisation (Art. 118) -> 0%
        Named MDB (Art. 117(2)) -> 0%
        MDB Table 2B (Art. 117(1))
        Institution (Art. 120 Table 3 / PS1/26 ECRA via
            ``build_institution_guarantor_rw_expr``)
        PSE (Art. 116(2) Table 2A, GB/other approximation for unrated)
        RGLA (Art. 115(1)(b) Table 1B, GB/other approximation for unrated)
        Corporate + covered bond (Art. 122 CRR Table 5 — see note below)
        Retail (Art. 123 flat 75%)
        High risk (Art. 128 flat 150%)
        else -> 1.0 (conservative preview default for unmatched entity
            types, e.g. equity / other items)

    Branch-parity notes (the pre-existing preview branches are preserved
    value-for-value):

    - The corporate branch always prices from ``corporate_risk_weights``
      (CRR Art. 122 Table 5), NOT the Basel 3.1 Table 6 — matching the
      historical preview. Covered bonds use the corporate-equivalent CQS RWs
      in the preview; the precise covered-bond table only applies in real
      SA pricing.
    - The unrated PSE / RGLA fallback is the documented SA-side
      approximation (see :func:`build_guarantor_rw_expr`): GB -> 20%
      domestic-currency treatment, other / unknown country -> 100% unrated
      default. When ``country_code_col`` is ``None`` the 100% default
      applies unconditionally.

    Args:
        entity_type_col: Name of the entity-type column. Null-filled to ""
            and lowercased before bucket routing.
        cqs_col: Name of the integer CQS column; null / out-of-range values
            fall to each table's unrated default.
        is_basel_3_1: Select the PS1/26 institution ECRA table when True,
            CRR Art. 120 Table 3 when False. All other preview branches are
            framework-identical by construction (see corporate note above).
        country_code_col: Optional name of the country-code column driving
            the unrated PSE / RGLA GB-vs-other approximation. ``None`` falls
            back to the conservative 100% unrated default.

    Returns:
        Float64 Polars expression evaluating to the entity's SA-equivalent
        preview risk weight (never null — unmatched entity types yield 1.0).
    """
    et = pl.col(entity_type_col).fill_null("").str.to_lowercase()

    sovereign_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CENTRAL_GOVT_CENTRAL_BANK.value])
    io_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INTERNATIONAL_ORGANISATION.value])
    mdb_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.MDB.value])
    institution_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.INSTITUTION.value])
    pse_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.PSE.value])
    rgla_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RGLA.value])
    corporate_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.CORPORATE.value])
    covered_bond_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.COVERED_BOND.value])
    retail_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.RETAIL_OTHER.value])
    high_risk_types = list(ENTITY_TYPES_BY_SA_CLASS[ExposureClass.HIGH_RISK.value])

    unrated_pse_rgla = _pse_rgla_unrated_fallback_expr(country_code_col)

    return (
        # CGCB (Art. 114 Table 1 — sovereign weights).
        pl.when(et.is_in(sovereign_types))
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _CGCB_RW,
                float(_CGCB_RW[CQS.UNRATED]),
            )
        )
        # International Organisation (Art. 118): 0% unconditional.
        .when(et.is_in(io_types))
        .then(pl.lit(float(_IO_ZERO_RW)))
        # Named MDB (Art. 117(2)): 0% unconditional — carved out ahead of Table 2B.
        .when(et == "mdb_named")
        .then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
        # Rated / unrated non-named MDB — Table 2B (Art. 117(1)).
        .when(et.is_in(mdb_types))
        .then(_cqs_table_lookup_expr(cqs_col, _MDB_RW, float(_MDB_UNRATED_RW)))
        # Institution — Art. 120 Table 3 / PS1/26 ECRA via the shared builder
        # so the pack remains the single source of truth.
        .when(et.is_in(institution_types))
        .then(build_institution_guarantor_rw_expr(cqs_col, is_basel_3_1))
        # PSE — Art. 116(2) Table 2A for rated, GB/other approximation for unrated.
        .when(et.is_in(pse_types))
        .then(_cqs_table_lookup_expr(cqs_col, _PSE_OWN_RW, unrated_pse_rgla))
        # RGLA — Art. 115(1)(b) Table 1B for rated, GB/other approximation for unrated.
        .when(et.is_in(rgla_types))
        .then(_cqs_table_lookup_expr(cqs_col, _RGLA_OWN_RW, unrated_pse_rgla))
        # Corporate + covered bond — CRR Art. 122 Table 5 (preview parity:
        # not framework-switched; see docstring).
        .when(et.is_in(corporate_types + covered_bond_types))
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _CORPORATE_RW,
                float(_CORPORATE_RW[CQS.UNRATED]),
            )
        )
        # Retail (Art. 123): flat 75%.
        .when(et.is_in(retail_types))
        .then(pl.lit(_RETAIL_RISK_WEIGHT))
        # High-risk items (Art. 128): flat 150%.
        .when(et.is_in(high_risk_types))
        .then(pl.lit(_HIGH_RISK_RW))
        # Conservative preview default for unmatched entity types.
        .otherwise(pl.lit(1.0))
    )
_crr_append_retail_branches — src/rwa_calc/engine/sa/risk_weights.py:722
@cites("CRR Art. 123")
def _crr_append_retail_branches(chain: _RWChain, uc: pl.Expr) -> ChainedThen:
    """Append CRR retail-class risk-weight branches (Art. 123).

    Covers the regulatory retail class only (uc contains "RETAIL"):
    - Non-regulatory retail (fails qualifying criteria): 100% (Art. 123(c)).
    - Payroll/pension loans: 35% (CRR Art. 123 second subparagraph, inserted
      by CRR2 Reg. (EU) 2019/876 F68 — scalar identical to PRA PS1/26
      Art. 123(4), reused from ``_SA_B31_RW``).
    - Regulatory retail (non-mortgage): 75% flat (Art. 123).

    The SME-managed-as-retail branch stays in the parent override (it gates
    on SME class membership, not just retail) and the corporate-SME branch
    is non-retail (Art. 122).
    """
    return (
        # Non-regulatory retail (fails qualifying criteria): 100%.
        chain.when(
            uc.str.contains("RETAIL", literal=True)
            & (pl.col("qualifies_as_retail").fill_null(False) == False)  # noqa: E712
        )
        .then(pl.lit(_SA_CRR_RW["non_reg_retail"]))
        # Payroll/pension loans: 35% (CRR Art. 123 second subparagraph,
        # inserted by CRR2 Reg. (EU) 2019/876 F68). Scalar identical to the
        # Basel 3.1 payroll RW (PRA PS1/26 Art. 123(4)), so the same
        # B31_RETAIL_PAYROLL_LOAN_RW constant is reused via _SA_B31_RW.
        .when(uc.str.contains("RETAIL", literal=True) & pl.col("is_payroll_loan").fill_null(False))
        .then(pl.lit(_SA_B31_RW["payroll"]))
        # Regulatory retail (non-mortgage): 75% flat.
        .when(uc.str.contains("RETAIL", literal=True))
        .then(pl.lit(_SA_SHARED_RW["retail"]))
    )
_build_qualifies_as_retail_expr — src/rwa_calc/engine/stages/classify/attributes.py:538
@cites("CRR Art. 123")
@cites("PS1/26, paragraph 123A")
def _build_qualifies_as_retail_expr(
    config: CalculationConfig,
    max_retail_exposure: float,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.Expr:
    """Build qualifies_as_retail expression with Art. 123A enforcement.

    CRR: Threshold check only — aggregated exposure ≤ EUR 1m.

    Basel 3.1 Art. 123A adds two-path qualifying criteria:
    - Art. 123A(1)(a): SME entities (revenue > 0 and < GBP 44m) auto-qualify
      without needing pool management attestation.
    - Art. 123A(1)(b)(ii): an obligor's aggregate exposure must not exceed
      GBP 880k (threshold limb) AND no single obligor's aggregate exposure may
      exceed 0.2% of the total regulatory-retail portfolio (granularity limb,
      BCBS CRE20.66). Both limbs are Basel-3.1-only. The granularity limb is
      gated on ``config.enforce_retail_granularity`` (default True) so it can
      be suppressed under CRE20.66's national-discretion clause.
    - Art. 123A(1)(b)(iii): Non-SME entities must be managed as part of a
      retail pool (cp_is_managed_as_retail=True) to qualify.  Null values
      default to True for backward compatibility.

    References:
        PRA PS1/26 Art. 123A(1)(a)-(b), CRR Art. 123
    """
    # Hierarchy resolver now populates lending_group_adjusted_exposure with the
    # counterparty aggregate when no lending group exists, so the threshold
    # check is a single comparison across both cases.
    threshold_fail = pl.col("lending_group_adjusted_exposure") > max_retail_exposure

    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
    if not resolved_pack.feature("retail_art_123a_two_path_applicable"):
        # CRR: threshold check only
        return (
            pl.when(threshold_fail)
            .then(pl.lit(False))
            .otherwise(pl.lit(True))
            .alias("qualifies_as_retail")
        )

    # Basel 3.1: Art. 123A two-path qualifying criteria.
    # Art. 123A(1)(a): SME auto-qualification — counterparty meets the
    # Art. 4(1)(128D) SME size test (turnover < EUR 50m OR balance-sheet
    # total < EUR 43m when turnover null).
    is_sme_for_art_123a = is_sme_by_size_expr(config, pack=resolved_pack)

    # Art. 123A(1)(b)(ii) granularity limb (BCBS CRE20.66): no single obligor's
    # aggregate exposure may exceed 0.2% of the total regulatory-retail
    # portfolio. Candidate-retail rows are the entity-type RETAIL_OTHER
    # population (``_sa_class``); the denominator counts each obligor once by
    # dividing the per-obligor aggregate (``lending_group_adjusted_exposure``)
    # by the obligor's line-count, masking non-retail rows to 0, then summing.
    granularity_limit = float(_RETAIL_GRANULARITY_LIMIT)
    is_retail_candidate = pl.col("_sa_class") == ExposureClass.RETAIL_OTHER.value
    obligor_agg = pl.col("lending_group_adjusted_exposure")
    # Guard the nullable ``counterparty_reference`` partition: a null key would
    # otherwise pool all unmapped rows into a single bucket (see
    # ``partition_by_nullable`` / ``NULLABLE_PARTITION_KEYS``). Null-keyed rows
    # count as their own single-line obligor.
    obligor_line_count = partition_by_nullable(
        pl.len().over("counterparty_reference"),
        "counterparty_reference",
        pl.lit(1),
    )
    portfolio_total = (
        pl.when(is_retail_candidate).then(obligor_agg / obligor_line_count).otherwise(pl.lit(0.0))
    ).sum()
    granularity_fail = (
        is_retail_candidate
        & (portfolio_total > 0)
        & (obligor_agg / portfolio_total > granularity_limit)
    )

    expr = (
        pl.when(threshold_fail)
        .then(pl.lit(False))
        # Art. 123A(1)(a): SMEs auto-qualify — no condition 3 needed
        .when(is_sme_for_art_123a)
        .then(pl.lit(True))
    )

    # Art. 123A(1)(b)(ii) granularity limb: > 0.2% of the retail portfolio.
    # Gated on config.enforce_retail_granularity (default True) so the limb
    # can be suppressed where granularity is assessed by another method under
    # CRE20.66's national-discretion clause, or to isolate the other limbs.
    if config.enforce_retail_granularity:
        expr = expr.when(granularity_fail).then(pl.lit(False))

    # Art. 123A(1)(b)(iii): Non-SME must be managed as retail pool.
    # Null defaults to True (Art. 123A — documented KEEP: a null pool-
    # management flag preserves backward-compatible qualifying behaviour).
    expr = expr.when(
        pl.col("cp_is_managed_as_retail").fill_null(True) == False  # noqa: E712
    ).then(pl.lit(False))

    return expr.otherwise(pl.lit(True)).alias("qualifies_as_retail")

CRR Art. 124 — Exposures secured by mortgages on immovable property

_crr_append_real_estate_branches — src/rwa_calc/engine/sa/risk_weights.py:756
@cites("CRR Art. 124")
def _crr_append_real_estate_branches(chain: _RWChain, uc: pl.Expr) -> ChainedThen:
    """Append CRR commercial-then-residential RE branches (Art. 125-126)."""
    ltv_safe = pl.col("ltv").fill_null(1.0)
    # CRR Art. 126(2)(d) proportion split for CRE with income cover and LTV > 50%:
    #   secured_share   = min(1.0, 50% / LTV)  -> portion attracting 50% RW
    #   residual_share  = 1.0 - secured_share  -> portion attracting unsecured
    #                     counterparty RW (Art. 124(1) -> Art. 122 corporate CQS)
    # When LTV <= 50% the clamp drives secured_share = 1.0 so the average collapses
    # to the preferential 50% RW, matching the pre-split behaviour.
    cre_secured_share = pl.min_horizontal(pl.lit(1.0), _SA_CRR_RW["cre_ltv_threshold"] / ltv_safe)
    cre_residual_share = pl.lit(1.0) - cre_secured_share
    # CRR Art. 124(1): the residual leg attracts the counterparty's UNSECURED
    # risk weight, i.e. the Art. 122 corporate CQS lookup — NOT a fixed 100%.
    # Look up counterparty CQS against CORPORATE_RISK_WEIGHTS directly (rather
    # than via the join-derived ``risk_weight``) so the rule still fires when
    # the upstream class lookup did not resolve to CORPORATE (e.g. exposures
    # reclassified to COMMERCIAL_MORTGAGE by the real-estate splitter).
    cre_residual_rw = _cqs_table_lookup_expr(
        "cqs",
        CORPORATE_RISK_WEIGHTS,
        pl.lit(float(CORPORATE_RISK_WEIGHTS[CQS.UNRATED])),
    )
    return (
        # Commercial RE must precede residential — see _is_commercial_re_class.
        # CRR Art. 126: LTV + income cover.
        chain.when(_is_commercial_re_class(uc))
        .then(
            pl.when(pl.col("has_income_cover").fill_null(False))
            .then(
                _SA_CRR_RW["cre_rw_low"] * cre_secured_share + cre_residual_rw * cre_residual_share
            )
            .otherwise(pl.lit(_SA_CRR_RW["cre_rw_standard"]))
        )
        # CRR Art. 125 LTV split.
        .when(_is_residential_re_class(uc))
        .then(
            pl.when(pl.col("ltv").fill_null(0.0) <= _SA_CRR_RW["resi_ltv_threshold"])
            .then(pl.lit(_SA_CRR_RW["resi_rw_low"]))
            .otherwise(
                _SA_CRR_RW["resi_rw_low"] * _SA_CRR_RW["resi_ltv_threshold"] / ltv_safe
                + _SA_CRR_RW["resi_rw_high"]
                * (ltv_safe - _SA_CRR_RW["resi_ltv_threshold"])
                / ltv_safe
            )
        )
    )

CRR Art. 125 — Exposures fully and completely secured by mortgages on residential property

split — src/rwa_calc/engine/stages/re_split/splitter.py:171
    @cites("CRR Art. 125")
    @cites("CRR Art. 126")
    @cites("PS1/26, paragraph 124F")
    def split(
        self,
        data: CRMAdjustedBundle,
        config: CalculationConfig,
        *,
        pack: ResolvedRulepack | None = None,
    ) -> CRMAdjustedBundle:
        """Apply RE loan-splitting to candidate rows.

        See module docstring for the regime-specific decision matrix.
        """
        # S9g: the RE-split regime gate reads the cited pack Feature; the split
        # parameter VALUES (LTV caps / RW) stay in data/tables/re_split_parameters.py,
        # and re_split_parameters / _split_unified_frame keep their is_basel_3_1 bool
        # plumbing params (Option B). One read feeds both the params lookup and the
        # allocation control flow.
        resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
        is_b31 = resolved_pack.feature("sa_re_split_revised_parameters")
        params = re_split_parameters(is_basel_3_1=is_b31)
        rrep = params["residential"]
        crep = params["commercial"]

        unified, audit, errors = _split_unified_frame(
            data.exposures,
            rrep=rrep,
            crep=crep,
            is_basel_3_1=is_b31,
        )

        # Producer seal (Phase 3): pure plan-level conform + brand — the
        # orchestrator materialises and re-seals at the re_split_exit stage
        # edge. Contract selected by the input frame's brand (CCR runs carry
        # the SA-CCR provenance columns through the split).
        exit_edge = (
            RE_SPLIT_EXIT_CCR_EDGE
            if sealed_edge_of(data.exposures) == "crm_exit_ccr"
            else RE_SPLIT_EXIT_EDGE
        )

        return CRMAdjustedBundle(
            exposures=seal(unified, exit_edge),
            equity_exposures=data.equity_exposures,
            ciu_holdings=data.ciu_holdings,
            collateral_allocation=data.collateral_allocation,
            collateral_link_allocation=data.collateral_link_allocation,
            re_split_audit=audit,
            securitisation_audit=data.securitisation_audit,
            crm_errors=list(data.crm_errors) + errors,
        )

CRR Art. 126 — Exposures fully and completely secured by mortgages on commercial immovable property

split — src/rwa_calc/engine/stages/re_split/splitter.py:172
    @cites("CRR Art. 125")
    @cites("CRR Art. 126")
    @cites("PS1/26, paragraph 124F")
    def split(
        self,
        data: CRMAdjustedBundle,
        config: CalculationConfig,
        *,
        pack: ResolvedRulepack | None = None,
    ) -> CRMAdjustedBundle:
        """Apply RE loan-splitting to candidate rows.

        See module docstring for the regime-specific decision matrix.
        """
        # S9g: the RE-split regime gate reads the cited pack Feature; the split
        # parameter VALUES (LTV caps / RW) stay in data/tables/re_split_parameters.py,
        # and re_split_parameters / _split_unified_frame keep their is_basel_3_1 bool
        # plumbing params (Option B). One read feeds both the params lookup and the
        # allocation control flow.
        resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
        is_b31 = resolved_pack.feature("sa_re_split_revised_parameters")
        params = re_split_parameters(is_basel_3_1=is_b31)
        rrep = params["residential"]
        crep = params["commercial"]

        unified, audit, errors = _split_unified_frame(
            data.exposures,
            rrep=rrep,
            crep=crep,
            is_basel_3_1=is_b31,
        )

        # Producer seal (Phase 3): pure plan-level conform + brand — the
        # orchestrator materialises and re-seals at the re_split_exit stage
        # edge. Contract selected by the input frame's brand (CCR runs carry
        # the SA-CCR provenance columns through the split).
        exit_edge = (
            RE_SPLIT_EXIT_CCR_EDGE
            if sealed_edge_of(data.exposures) == "crm_exit_ccr"
            else RE_SPLIT_EXIT_EDGE
        )

        return CRMAdjustedBundle(
            exposures=seal(unified, exit_edge),
            equity_exposures=data.equity_exposures,
            ciu_holdings=data.ciu_holdings,
            collateral_allocation=data.collateral_allocation,
            collateral_link_allocation=data.collateral_link_allocation,
            re_split_audit=audit,
            securitisation_audit=data.securitisation_audit,
            crm_errors=list(data.crm_errors) + errors,
        )

CRR Art. 127 — Exposures in default

_apply_defaulted_risk_weight — src/rwa_calc/engine/sa/risk_weights.py:1448
@cites("CRR Art. 127")
@cites("PS1/26, paragraph 127")
def _apply_defaulted_risk_weight(
    exposures: pl.LazyFrame,
    config: CalculationConfig,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """Apply Art. 127 defaulted risk weight to the full post-CRM exposure.

    PS1/26 Art. 127(1)-(2) assign 100% or 150% to the part of a defaulted
    exposure that is not secured by recognised collateral or covered by
    recognised unfunded credit protection, where the unsecured part is
    determined by the CRM method the institution applies (Art. 191A(2)).

    Under the Financial Collateral Comprehensive Method (the default for
    SA), eligible financial collateral has already reduced ``ead_final``
    in the CRM stage and eligible residential/commercial real estate has
    been routed via class reclassification — so ``ead_final`` already
    represents the unsecured value and Art. 127(1) applies to it flat.
    FCSM (Simple Method) is handled downstream by
    ``apply_fcsm_rw_substitution``, which blends the defaulted RW with
    the collateral RW per the substitution rule.

    Basel 3.1 Art. 127(3) / CRE20.88 exception: a residential RE default
    that is not materially dependent on cash-flows of the property is
    assigned 100% flat, regardless of provisions.

    References:
    - PS1/26 Art. 127(1): unsecured part 100%/150% by provision coverage
    - PS1/26 Art. 127(2): unsecured part determined by the CRM method
    - PS1/26 Art. 127(3) / CRE20.88: RESI RE non-income flat 100%
    - CRR Art. 127(1)-(2): CRR predecessor (pre-provision denominator)
    """
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
    _uc = pl.col("exposure_class").fill_null("").str.to_uppercase()
    ead = pl.col("ead_final")

    if resolved_pack.feature("sa_revised_defaulted_treatment"):
        # B31 RESI RE non-income-dependent: 100% flat (Art. 127(3) / CRE20.88).
        is_resi_re_non_income = (
            _is_residential_re_class(_uc)
            & ~_is_commercial_re_class(_uc)
            & ~pl.col("has_income_cover").fill_null(False)
        )

        # PS1/26 Art. 127(1): denominator is "the outstanding amount of the
        # item or facility" — gross outstanding (pre-CRM, pre-provision).
        # Reconstruct from ead_gross (post-CCF, post-provision, pre-CRM) plus
        # provision_deducted.
        gross_outstanding = pl.col("ead_gross") + pl.col("provision_deducted")
        provision_rw = (
            pl.when(
                pl.col("provision_allocated")
                >= _SA_B31_RW["defaulted_threshold"] * gross_outstanding
            )
            .then(pl.lit(_SA_B31_RW["defaulted_high"]))
            .otherwise(pl.lit(_SA_B31_RW["defaulted_low"]))
        )

        defaulted_rw = (
            pl.when(is_resi_re_non_income)
            .then(pl.lit(_SA_B31_RW["defaulted_resi_re_non_income"]))
            .otherwise(provision_rw)
        )
    else:
        # CRR Art. 127(1): denominator is the pre-provision exposure value
        # (ead_final is post-provision, so add provision_deducted back).
        unsecured_pre_prov = ead + pl.col("provision_deducted")
        defaulted_rw = (
            pl.when(
                pl.col("provision_allocated")
                >= _SA_CRR_RW["defaulted_threshold"] * unsecured_pre_prov
            )
            .then(pl.lit(_SA_CRR_RW["defaulted_high"]))
            .otherwise(pl.lit(_SA_CRR_RW["defaulted_low"]))
        )

    # Art. 128 (HIGH_RISK) takes precedence per Table A2 priority 4 > 5
    is_defaulted = pl.col("is_defaulted").fill_null(False) & (_uc != "HIGH_RISK")

    return exposures.with_columns(
        pl.when(is_defaulted)
        .then(defaulted_rw)
        .otherwise(pl.col("risk_weight"))
        .alias("risk_weight")
    )

CRR Art. 128 — Items associated with particular high risk

UK CRR omitted Art. 128 by SI 2021/1078 reg. 6(3)(a) (effective 1 Jan 2022) — exposures that would have attracted 150% fall through to the 100% OTHER class under UK CRR. The 150% treatment is reintroduced under Basel 3.1 — see PS1/26, paragraph 128 for the live decorator on engine/sa/namespace.py::_b31_append_high_risk_branch.

CRR Art. 129 — Exposures in the form of covered bonds

_create_covered_bond_df — src/rwa_calc/engine/sa/crr_risk_weight_tables.py:538
@cites("CRR Art. 129")
def _create_covered_bond_df() -> pl.DataFrame:
    """Create covered bond risk weight lookup DataFrame (CRR Art. 129)."""
    return _build_cqs_rw_df(
        COVERED_BOND_RISK_WEIGHTS,
        "COVERED_BOND",
        order=_CQS_ORDER_RATED_ONLY,
    )
_crr_unrated_cb_rw_expr — src/rwa_calc/engine/sa/risk_weights.py:438
@cites("CRR Art. 129")
def _crr_unrated_cb_rw_expr() -> pl.Expr:
    """Build Polars expression for CRR Art. 129(5) unrated covered bond RW derivation.

    Derives covered bond RW from the issuing institution's CQS via two-step lookup:
      1. Institution CQS → institution RW (Art. 120 Table 3)
      2. Institution RW → covered bond RW (Art. 129(5) derivation table)

    When ``cp_institution_cqs`` is null (institution itself is unrated), uses
    Art. 121 fallback institution RW (100%) → CB 50%.

    References:
        CRR Art. 120 Table 3: Institution risk weights (CQS 2 = 50%)
        CRR Art. 129(5): Unrated covered bond derivation from institution RW
    """
    inst_table = INSTITUTION_RISK_WEIGHTS_CRR

    # Pre-compute CQS → CB RW by chaining institution RW through the derivation table.
    # CRR Art. 129(5) admits only four sub-paragraphs (a)-(d); use the CRR-specific
    # 4-key dict so (b) maps 0.50 -> 0.20, not the B31 value 0.25.
    cqs_to_cb_rw: dict[int, float] = {}
    for cqs_val in [CQS.CQS1, CQS.CQS2, CQS.CQS3, CQS.CQS4, CQS.CQS5, CQS.CQS6]:
        inst_rw = inst_table[cqs_val]
        cb_rw = COVERED_BOND_UNRATED_DERIVATION_CRR[inst_rw]
        cqs_to_cb_rw[int(cqs_val)] = float(cb_rw)

    # Unrated institution: sovereign-derived
    unrated_inst_rw = inst_table[CQS.UNRATED]
    unrated_cb_rw = float(COVERED_BOND_UNRATED_DERIVATION_CRR[unrated_inst_rw])

    # Build when/then chain from cp_institution_cqs
    expr = pl.when(pl.col("cp_institution_cqs") == 1).then(pl.lit(cqs_to_cb_rw[1]))
    for cqs_int in [2, 3, 4, 5, 6]:
        expr = expr.when(pl.col("cp_institution_cqs") == cqs_int).then(
            pl.lit(cqs_to_cb_rw[cqs_int])
        )
    # Fallback: cp_institution_cqs is null (unrated institution) or unexpected value
    return expr.otherwise(pl.lit(unrated_cb_rw))
_b31_unrated_cb_rw_expr — src/rwa_calc/engine/sa/risk_weights.py:478
@cites("CRR Art. 129")
@cites("PS1/26, paragraph 129")
def _b31_unrated_cb_rw_expr() -> pl.Expr:
    """Build Polars expression for B31 Art. 129(5) unrated covered bond RW derivation.

    Derives covered bond RW from the issuing institution's senior unsecured RW,
    which can come from either source:
      1. ECRA (rated institution): cp_institution_cqs → institution RW → CB RW
      2. SCRA (unrated institution): cp_scra_grade → CB RW

    Art. 129(5) operates on the resulting institution RW regardless of source
    (ECRA or SCRA). The ECRA path is checked first; if cp_institution_cqs is
    null, falls back to the SCRA path.

    References:
        PRA PS1/26 Art. 120 Table 3 ECRA: Institution risk weights (CQS 2 = 30%)
        PRA PS1/26 Art. 120A: SCRA institution risk weights
        PRA PS1/26 Art. 129(5): Unrated covered bond derivation from institution RW
    """
    inst_table = INSTITUTION_RISK_WEIGHTS_B31_ECRA
    cqs_to_cb_rw: dict[int, float] = {}
    for cqs_val in [CQS.CQS1, CQS.CQS2, CQS.CQS3, CQS.CQS4, CQS.CQS5, CQS.CQS6]:
        inst_rw = inst_table[cqs_val]
        cb_rw = COVERED_BOND_UNRATED_DERIVATION_B31[inst_rw]
        cqs_to_cb_rw[int(cqs_val)] = float(cb_rw)

    # Build when/then: ECRA first (cp_institution_cqs)
    expr = pl.when(pl.col("cp_institution_cqs") == 1).then(pl.lit(cqs_to_cb_rw[1]))
    for cqs_int in [2, 3, 4, 5, 6]:
        expr = expr.when(pl.col("cp_institution_cqs") == cqs_int).then(
            pl.lit(cqs_to_cb_rw[cqs_int])
        )
    # SCRA fallback (cp_scra_grade) for unrated issuers
    for grade, cb_rw in B31_COVERED_BOND_UNRATED_FROM_SCRA.items():
        expr = expr.when(pl.col("cp_scra_grade") == grade).then(pl.lit(float(cb_rw)))
    # Conservative default: Grade C equivalent (B31_COVERED_BOND_UNRATED_FROM_SCRA["C"])
    return expr.otherwise(pl.lit(_SA_B31_RW["unrated_cb_default"]))

CRR Art. 130 — Items representing securitisation positions

Out of scope — securitisation is handled by a separate calculator domain (CRR Title II, Chapter 5). This calculator covers Chapters 1-4 only.

CRR Art. 131 — Exposures to institutions and corporates with a short-term credit assessment

apply_short_term_rating_override — src/rwa_calc/engine/stages/hierarchy/enrich.py:158
@cites("CRR Art. 131")
@cites("CRR Art. 140")
def apply_short_term_rating_override(
    exposures: pl.LazyFrame,
    ratings: pl.LazyFrame | None,
) -> pl.LazyFrame:
    """Apply per-exposure short-term rating override.

    Short-term ECAI assessments under PRA PS1/26 Art. 120(2B) Table 4A and
    Art. 122(3) Table 6A are issue-specific — each rating row attaches to a
    single exposure via ``(scope_type, scope_id)``. When a short-term rating
    row matches an exposure, its ``cqs`` overrides the counterparty-level
    rating attached by ``attach_counterparty_rating`` and the derived
    ``has_short_term_ecai`` flag is set to True, signalling the SA engine to
    route via Table 4A / Table 6A.

    Scope matching:

    - ``scope_type='facility'``  -> matches the source facility's drawn loans,
      its synthetic ``facility_undrawn`` row, and any descendant exposure via
      ``parent_facility_reference`` / ``root_facility_reference``.
    - ``scope_type='loan'``      -> matches the loan exposure with the same
      ``exposure_reference`` and ``exposure_type='loan'``.
    - ``scope_type='contingent'`` -> matches the contingent exposure with the
      same ``exposure_reference`` and ``exposure_type='contingent'``.

    Ties (multiple short-term ratings for the same exposure) are resolved by
    picking the row with the lowest CQS, breaking ties by latest
    ``rating_date``. This mirrors the external best-rating selection in
    ``ratings.build_rating_inheritance_lazy``.

    Always returns ``exposures`` augmented with a ``has_short_term_ecai``
    boolean column (False when no override matched).
    """
    st_ratings = _prepare_short_term_lookup(ratings)
    if st_ratings is None:
        return exposures.with_columns(pl.lit(False).alias("has_short_term_ecai"))

    exp_schema = set(exposures.collect_schema().names())
    match_branches = _build_short_term_match_branches(exp_schema)

    # Track which scope branches actually produce a match so we can
    # coalesce the resulting cqs in priority order: loan > contingent >
    # facility (most specific wins).
    joined_scopes: list[str] = []
    for scope, key_expr in match_branches:
        scope_lookup = st_ratings.filter(pl.col("_st_scope_type") == scope).select(
            [
                pl.col("_st_cp"),
                pl.col("_st_scope_id"),
                pl.col("_st_cqs").alias(f"_st_{scope}_cqs"),
            ]
        )
        exposures = exposures.with_columns(key_expr.alias(f"_match_key_{scope}"))
        exposures = exposures.join(
            scope_lookup,
            left_on=["counterparty_reference", f"_match_key_{scope}"],
            right_on=["_st_cp", "_st_scope_id"],
            how="left",
        ).drop(f"_match_key_{scope}")
        joined_scopes.append(scope)

    if not joined_scopes:
        return exposures.with_columns(pl.lit(False).alias("has_short_term_ecai"))

    # Coalesce in priority order: loan > contingent > facility (most
    # specific scope wins).
    priority = ["loan", "contingent", "facility"]
    ordered = [s for s in priority if s in joined_scopes]
    st_cqs_expr = pl.coalesce([pl.col(f"_st_{s}_cqs") for s in ordered])

    # Override: when the short-term cqs is non-null, replace the cqs column
    # and set has_short_term_ecai=True. SA Tables 4A / 6A are keyed off cqs
    # only — rating_agency / rating_value are audit columns added later by
    # the classifier and intentionally not overridden here.
    has_st = st_cqs_expr.is_not_null()
    exposures = exposures.with_columns(
        [
            has_st.alias("has_short_term_ecai"),
            pl.when(has_st).then(st_cqs_expr).otherwise(pl.col("cqs")).cast(pl.Int8).alias("cqs"),
        ]
    )
    return exposures.drop([f"_st_{s}_cqs" for s in joined_scopes])

CRR Art. 132 — Exposures in the form of units or shares in collective investment undertakings (CIUs)

UK CRR omitted Art. 132; PRA reintroduced CIU treatment via PS1/26 paragraph 132. Implementation lives at engine/equity/calculator.py::_append_ciu_branches and is cited under PS1/26, paragraph 132 in the PS1/26 section below.

CRR Art. 133 — Equity exposures

calculate_branch — src/rwa_calc/engine/equity/calculator.py:176
    @cites("CRR Art. 133")
    def calculate_branch(
        self,
        exposures: pl.LazyFrame,
        config: CalculationConfig,
    ) -> pl.LazyFrame:
        """
        Calculate equity RWA on pre-filtered equity-only rows.

        Args:
            exposures: Pre-filtered equity rows only
            config: Calculation configuration

        Returns:
            LazyFrame with equity RWA columns populated
        """
        approach = self._determine_approach(config)

        exposures = self._prepare_columns(exposures, config)

        # Art. 155(3) PD/LGD computes RWEA inside the branch (K formula) and
        # bypasses both the IRB Simple transitional floor and _calculate_rwa.
        if approach == EquityApproach.PD_LGD:
            return self._apply_equity_weights_pd_lgd(exposures, config)

        if approach == EquityApproach.IRB_SIMPLE:
            exposures = self._apply_equity_weights_irb_simple(exposures, config)
        else:
            exposures = self._apply_equity_weights_sa(exposures, config)

        exposures = self._apply_transitional_floor(exposures, config)

        return self._calculate_rwa(exposures)
get_equity_result_bundle — src/rwa_calc/engine/equity/calculator.py:210
    @cites("CRR Art. 133")
    @cites("CRR Art. 155")
    def get_equity_result_bundle(
        self,
        data: CRMAdjustedBundle,
        config: CalculationConfig,
        *,
        pack: ResolvedRulepack | None = None,
    ) -> EquityResultBundle:
        """
        Calculate equity RWA and return as a bundle.

        Args:
            data: CRM-adjusted exposures
            config: Calculation configuration

        Returns:
            EquityResultBundle with results and audit trail
        """
        errors: list[CalculationError] = []

        exposures = data.equity_exposures

        if exposures is None:
            empty_frame = pl.LazyFrame(
                {
                    "exposure_reference": pl.Series([], dtype=pl.String),
                    "equity_type": pl.Series([], dtype=pl.String),
                    "ead_final": pl.Series([], dtype=pl.Float64),
                    "risk_weight": pl.Series([], dtype=pl.Float64),
                    "rwa": pl.Series([], dtype=pl.Float64),
                }
            )
            return EquityResultBundle(
                results=empty_frame,
                calculation_audit=empty_frame,
                approach=EquityApproach.SA,
                errors=[],
            )

        approach = self._determine_approach(config, pack=pack)

        exposures = self._prepare_columns(exposures, config)
        exposures = self._resolve_look_through_rw(exposures, data.ciu_holdings, config, pack=pack)

        # Art. 155(3) PD/LGD computes RWEA inside the branch and bypasses both
        # the IRB Simple transitional floor and _calculate_rwa.
        if approach == EquityApproach.PD_LGD:
            exposures = self._apply_equity_weights_pd_lgd(exposures, config, pack=pack)
        else:
            if approach == EquityApproach.IRB_SIMPLE:
                exposures = self._apply_equity_weights_irb_simple(exposures, config)
            else:
                exposures = self._apply_equity_weights_sa(exposures, config, pack=pack)

            exposures = self._apply_transitional_floor(exposures, config, pack=pack)
            exposures = self._calculate_rwa(exposures)

        audit = self._build_audit(exposures, approach)

        return EquityResultBundle(
            results=exposures,
            calculation_audit=audit,
            approach=approach,
            errors=errors,
        )

CRR Art. 134 — Other items

_apply_b31_risk_weight_overrides — src/rwa_calc/engine/sa/risk_weights.py:990
@cites("CRR Art. 134")
@cites("CRR Art. 137")
def _apply_b31_risk_weight_overrides(
    exposures: pl.LazyFrame,
    uc: pl.Expr,
    is_domestic_currency: pl.Expr,
    config: CalculationConfig,
) -> pl.LazyFrame:
    """Apply Basel 3.1 class-specific risk-weight overrides (CRE20, PRA PS1/26)."""
    # Save the CQS-based risk weight before overrides — needed for the
    # Basel 3.1 general CRE min(60%, counterparty_rw) logic (CRE20.85).
    exposures = exposures.with_columns(
        pl.col("risk_weight").fill_null(1.0).alias("_cqs_risk_weight")
    )

    # Build the override chain in regulatory precedence order:
    #   CGCB / QCCP / subordinated debt   [early overrides, before RE/CQS]
    #   real estate                        (ADC, other-RE, residential, commercial)
    #   sovereign-like                     (PSE, RGLA)
    #   MDB / IO
    #   institution maturity               (ECRA short, SCRA short, SCRA long)
    #   corporate / retail / misc          (IG, SME, SL, QRRE, payroll, retail, ...)
    #   covered bond / high risk / other items / equity
    chain = (
        pl.when(pl.col("risk_type") == _SETTLEMENT_FAILED_TRADE_RISK_TYPE)  # P8.43 failed trade
        .then(pl.lit(_OWN_FUNDS_TO_RWA_FACTOR))
        .when(
            pl.col("risk_type") == _CCR_DEFAULT_FUND_RISK_TYPE
        )  # P8.49 default fund (Art. 308/309)
        .then(pl.lit(_OWN_FUNDS_TO_RWA_FACTOR))
        .when(uc.str.contains("CENTRAL_GOVT", literal=True) & is_domestic_currency)
        .then(pl.lit(0.0))
        # Art. 137(1)-(2) Table 9: nominated ECA / MEIP score → direct sovereign
        # RW when no ECAI rating is present. Takes precedence over the Art. 114
        # unrated 100% fallback but not over the Art. 114(4)/(7) domestic 0%.
        # Identical to the CRR arm — MEIP risk weights are unchanged under PS1/26.
        .when(
            uc.str.contains("CENTRAL_GOVT", literal=True)
            & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
            & pl.col("cp_eca_score").is_not_null()
        )
        .then(_eca_meip_rw_expr())
        # QCCP trade exposures (CRR Art. 306, CRE54.14-15). The 2%/4% pin is
        # for QUALIFYING CCPs only (Art. 272 Def (88)): an explicit
        # cp_is_qccp=False demotes a ``ccp`` entity_type to the standard
        # institution ladder (Art. 107(2)(a)). An absent flag is treated as
        # qualifying so legacy ``ccp`` rows keep the prescribed weight.
        .when((pl.col("cp_entity_type") == "ccp") & pl.col("cp_is_qccp").fill_null(True))
        .then(
            pl.when(pl.col("cp_is_ccp_client_cleared").fill_null(False))
            .then(pl.lit(_SA_SHARED_RW["qccp_client_cleared"]))
            .otherwise(pl.lit(_SA_SHARED_RW["qccp_proprietary"]))
        )
        # Subordinated debt: flat 150% (CRE20.47) — overrides all CQS-based
        # weights for institution + corporate.
        .when(
            (pl.col("seniority").fill_null("senior") == "subordinated")
            & (
                uc.str.contains("INSTITUTION", literal=True)
                | uc.str.contains("CORPORATE", literal=True)
            )
        )
        .then(pl.lit(_SA_B31_RW["sub_debt"]))
    )

    chain = _b31_append_real_estate_branches(chain, uc)

    # Sovereign-like treatments (PSE then RGLA).
    chain = (
        # PSE short-term (Art. 116(3)): original maturity <= 3m -> 20% flat.
        # Art. 116(3) keys on ORIGINAL maturity — a seasoned long-dated PSE
        # bond with short residual does not qualify.
        chain.when(
            (uc == "PSE")
            & pl.col("original_maturity_years").is_not_null()
            & (pl.col("original_maturity_years") <= 0.25)
        )
        .then(pl.lit(_SA_SHARED_RW["pse_short_term"]))
        # PSE unrated: sovereign-derived RW lookup (Art. 116(1), Table 2).
        # Maps cp_sovereign_cqs -> RW; falls back to 100% when sovereign
        # CQS is unknown.
        .when((uc == "PSE") & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0)))
        .then(
            _sovereign_derived_rw_expr(
                PSE_RISK_WEIGHTS_SOVEREIGN_DERIVED,
                _SA_SHARED_RW["pse_unrated"],
            )
        )
        # RGLA UK devolved govt -> 0% (PRA designation).
        .when(
            (uc == "RGLA")
            & (pl.col("cp_entity_type").fill_null("") == "rgla_sovereign")
            & (pl.col("cp_country_code") == "GB")
        )
        .then(pl.lit(_SA_SHARED_RW["rgla_uk_devolved"]))
        # RGLA domestic currency -> 20% (Art. 115(5)).
        .when((uc == "RGLA") & is_domestic_currency)
        .then(pl.lit(_SA_SHARED_RW["rgla_domestic"]))
        # RGLA unrated non-domestic: sovereign-derived (Art. 115(1)(a)
        # Table 1A). Maps cp_sovereign_cqs -> RW; falls back to 100% when
        # sovereign CQS is unknown.
        .when((uc == "RGLA") & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0)))
        .then(
            _sovereign_derived_rw_expr(
                RGLA_RISK_WEIGHTS_SOVEREIGN_DERIVED,
                _SA_SHARED_RW["rgla_unrated"],
            )
        )
        # International Organisation -> 0% (Art. 118).
        .when(uc == "INTERNATIONAL_ORGANISATION")
        .then(pl.lit(_SA_SHARED_RW["io"]))
        # Named MDB -> 0% (Art. 117(2)).
        .when((uc == "MDB") & (pl.col("cp_entity_type").fill_null("") == "mdb_named"))
        .then(pl.lit(_SA_SHARED_RW["mdb_named"]))
        # Unrated non-named MDB -> 50% (Art. 117(1), Table 2B).
        .when((uc == "MDB") & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0)))
        .then(pl.lit(_SA_SHARED_RW["mdb_unrated"]))
    )

    chain = _b31_append_institution_maturity_branches(chain, uc)
    chain = _b31_append_corporate_maturity_branches(chain, uc)
    chain = _b31_append_high_risk_branch(chain, uc)

    # Corporate / retail / misc tail of the chain.
    is_unrated_corporate = (
        uc.str.contains("CORPORATE", literal=True)
        & ~uc.str.contains("SME", literal=True)
        & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
    )
    chain = (
        chain
        # Investment-grade assessment (Art. 122(6)/(8)) — only active under
        # use_investment_grade_assessment. IG -> 65%, non-IG -> 135%.
        .when(
            pl.lit(config.use_investment_grade_assessment)
            & is_unrated_corporate
            & (pl.col("cp_is_investment_grade").fill_null(False) == True)  # noqa: E712
        )
        .then(pl.lit(_SA_B31_RW["corporate_ig"]))
        .when(
            pl.lit(config.use_investment_grade_assessment)
            & is_unrated_corporate
            & (pl.col("cp_is_investment_grade").fill_null(False) != True)  # noqa: E712
        )
        .then(pl.lit(_SA_B31_RW["corporate_nig"]))
        # SME managed as retail: 75% (Art. 123, aggregated <= EUR 1m).
        .when(
            uc.str.contains("SME", literal=True)
            & (pl.col("cp_is_managed_as_retail") == True)  # noqa: E712
            & (pl.col("qualifies_as_retail") == True)  # noqa: E712
        )
        .then(pl.lit(_SA_SHARED_RW["retail"]))
        # SA Specialised Lending — unrated only (Art. 122A-122B). Rated SL
        # exposures use the corporate CQS table (Art. 122A(3)).
        .when(
            (
                uc.str.contains("SPECIALISED", literal=True)
                | (pl.col("sl_type").fill_null("").str.len_chars() > 0)
            )
            & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
        )
        .then(b31_sa_sl_rw_expr())
        # Corporate SME: 85% — unrated only (Art. 122(11)). A rated SME
        # (CQS 1-6) keeps its Art. 122(2) Table-6 weight from the rw_table join.
        .when(
            uc.str.contains("CORPORATE", literal=True)
            & uc.str.contains("SME", literal=True)
            & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
        )
        .then(pl.lit(_SA_B31_RW["corporate_sme"]))
    )

    # Retail-class branches (Art. 123).
    chain = _b31_append_retail_branches(chain, uc)

    exposures = exposures.with_columns(
        chain
        # Unrated covered bonds: derive from issuer institution RW
        # (Art. 129(5)). ECRA (rated issuer, cp_institution_cqs) checked
        # first, then SCRA (unrated issuer, cp_scra_grade) as fallback.
        .when(
            uc.str.contains("COVERED_BOND", literal=True)
            & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
        )
        .then(_b31_unrated_cb_rw_expr())
        # Other Items (Art. 134): sub-type-specific risk weights.
        .when(
            (uc == "OTHER")
            & (pl.col("cp_entity_type").fill_null("").is_in(["other_cash", "other_gold"]))
        )
        .then(pl.lit(_SA_SHARED_RW["other_cash"]))
        .when(
            (uc == "OTHER")
            & (pl.col("cp_entity_type").fill_null("") == "other_items_in_collection")
        )
        .then(pl.lit(_SA_SHARED_RW["other_collection"]))
        .when((uc == "OTHER") & (pl.col("cp_entity_type").fill_null("") == "other_residual_lease"))
        .then(pl.lit(1.0) / pl.col("residual_maturity_years").fill_null(1.0).clip(lower_bound=1.0))
        .when(uc == "OTHER")
        .then(pl.lit(_SA_SHARED_RW["other_default"]))
        # Equity (Art. 133(3)): 250% — full equity treatment (CIU,
        # transitional floor) lives in the dedicated equity table.
        .when(uc == "EQUITY")
        .then(pl.lit(_SA_B31_RW["equity"]))
        .otherwise(pl.col("risk_weight").fill_null(1.0))
        .alias("risk_weight")
    )
    return exposures
_apply_crr_risk_weight_overrides — src/rwa_calc/engine/sa/risk_weights.py:1200
@cites("CRR Art. 134")
@cites("CRR Art. 137")
def _apply_crr_risk_weight_overrides(
    exposures: pl.LazyFrame,
    uc: pl.Expr,
    is_domestic_currency: pl.Expr,
) -> pl.LazyFrame:
    """Apply CRR class-specific risk-weight overrides (Art. 112-134)."""
    chain = (
        pl.when(pl.col("risk_type") == _SETTLEMENT_FAILED_TRADE_RISK_TYPE)  # P8.43 failed trade
        .then(pl.lit(_OWN_FUNDS_TO_RWA_FACTOR))
        .when(
            pl.col("risk_type") == _CCR_DEFAULT_FUND_RISK_TYPE
        )  # P8.49 default fund (Art. 308/309)
        .then(pl.lit(_OWN_FUNDS_TO_RWA_FACTOR))
        # Art. 114(4)/(7): Domestic CGCB -> 0% RW (overrides all CQS).
        .when(uc.str.contains("CENTRAL_GOVT", literal=True) & is_domestic_currency)
        .then(pl.lit(0.0))
        # Art. 137(1)-(2) Table 9: nominated ECA / MEIP score → direct sovereign
        # RW when no ECAI rating is present. Takes precedence over the Art. 114
        # unrated 100% fallback but not over the Art. 114(4)/(7) domestic 0%.
        .when(
            uc.str.contains("CENTRAL_GOVT", literal=True)
            & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
            & pl.col("cp_eca_score").is_not_null()
        )
        .then(_eca_meip_rw_expr())
        # QCCP trade exposures (CRR Art. 306, CRE54.14-15). The 2%/4% pin is
        # for QUALIFYING CCPs only (Art. 272 Def (88)): an explicit
        # cp_is_qccp=False demotes a ``ccp`` entity_type to the standard
        # institution ladder (Art. 107(2)(a)). An absent flag is treated as
        # qualifying so legacy ``ccp`` rows keep the prescribed weight.
        .when((pl.col("cp_entity_type") == "ccp") & pl.col("cp_is_qccp").fill_null(True))
        .then(
            pl.when(pl.col("cp_is_ccp_client_cleared").fill_null(False))
            .then(pl.lit(_SA_SHARED_RW["qccp_client_cleared"]))
            .otherwise(pl.lit(_SA_SHARED_RW["qccp_proprietary"]))
        )
    )

    chain = _crr_append_real_estate_branches(chain, uc)

    # SME / retail branches.
    chain = (
        # SME managed as retail: 75% (CRR Art. 123, aggregated <= EUR 1m).
        chain.when(
            uc.str.contains("SME", literal=True)
            & (pl.col("cp_is_managed_as_retail") == True)  # noqa: E712
            & (pl.col("qualifies_as_retail") == True)  # noqa: E712
        )
        .then(pl.lit(_SA_SHARED_RW["retail"]))
        # Corporate SME: 100% — unrated only (Art. 122). A rated SME (CQS 1-6)
        # keeps its Art. 122 CQS-table weight from the rw_table join; SME relief
        # is delivered separately via the Art. 501 supporting factor.
        .when(
            uc.str.contains("CORPORATE", literal=True)
            & uc.str.contains("SME", literal=True)
            & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
        )
        .then(pl.lit(_SA_CRR_RW["corporate_sme"]))
    )

    # Retail-class branches (Art. 123).
    chain = _crr_append_retail_branches(chain, uc)

    # Sovereign-like (PSE, RGLA, MDB, IO).
    chain = (
        # PSE short-term (Art. 116(3)): original maturity <= 3m -> 20%.
        chain.when(
            (uc == "PSE")
            & pl.col("original_maturity_years").is_not_null()
            & (pl.col("original_maturity_years") <= 0.25)
        )
        .then(pl.lit(_SA_SHARED_RW["pse_short_term"]))
        # PSE unrated: sovereign-derived RW lookup (Art. 116(1), Table 2).
        # Maps cp_sovereign_cqs -> RW; falls back to 100% when sovereign
        # CQS is unknown.
        .when((uc == "PSE") & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0)))
        .then(
            _sovereign_derived_rw_expr(
                PSE_RISK_WEIGHTS_SOVEREIGN_DERIVED,
                _SA_SHARED_RW["pse_unrated"],
            )
        )
        # RGLA UK devolved govt -> 0% (PRA designation).
        .when(
            (uc == "RGLA")
            & (pl.col("cp_entity_type").fill_null("") == "rgla_sovereign")
            & (pl.col("cp_country_code") == "GB")
        )
        .then(pl.lit(_SA_SHARED_RW["rgla_uk_devolved"]))
        # RGLA domestic currency -> 20% (Art. 115(5)).
        .when((uc == "RGLA") & is_domestic_currency)
        .then(pl.lit(_SA_SHARED_RW["rgla_domestic"]))
        # RGLA unrated non-domestic: sovereign-derived (Art. 115(1)(a)
        # Table 1A). Maps cp_sovereign_cqs -> RW; falls back to 100% when
        # sovereign CQS is unknown.
        .when((uc == "RGLA") & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0)))
        .then(
            _sovereign_derived_rw_expr(
                RGLA_RISK_WEIGHTS_SOVEREIGN_DERIVED,
                _SA_SHARED_RW["rgla_unrated"],
            )
        )
        # International Organisation -> 0% (Art. 118).
        .when(uc == "INTERNATIONAL_ORGANISATION")
        .then(pl.lit(_SA_SHARED_RW["io"]))
        # Named MDB -> 0% (Art. 117(2)).
        .when((uc == "MDB") & (pl.col("cp_entity_type").fill_null("") == "mdb_named"))
        .then(pl.lit(_SA_SHARED_RW["mdb_named"]))
        # CRR Art. 117(1): non-named MDBs are treated as institutions and use
        # the institution risk weight tables (Art. 120 Table 3 if rated, Art.
        # 121 Table 5 sovereign-derived if unrated). The dedicated Basel 3.1
        # Table 2B path (PRA PS1/26 Art. 117(1)(a)) does NOT apply under CRR.
        # The Art. 119(2)/120(2)/121(3) short-term carve-outs are excluded for
        # MDBs by Art. 117(1), so no short-term branch is consulted here.
        # Rated non-named MDB: Art. 120 Table 3 (institution own CQS).
        .when((uc == "MDB") & pl.col("cqs").is_not_null() & (pl.col("cqs") > 0))
        .then(build_institution_guarantor_rw_expr("cqs", is_basel_3_1=False))
        # Unrated non-named MDB: Art. 121 Table 5 sovereign-derived; Art. 121
        # fallback (100%) when the MDB's home sovereign CQS is unknown.
        .when((uc == "MDB") & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0)))
        .then(
            _sovereign_derived_rw_expr(
                INSTITUTION_RISK_WEIGHTS_SOVEREIGN_DERIVED,
                float(INSTITUTION_RISK_WEIGHTS_CRR[CQS.UNRATED]),
            )
        )
    )

    chain = _crr_append_institution_maturity_branches(chain, uc)

    # Covered bond / high risk / other items / equity tail.
    exposures = exposures.with_columns(
        chain
        # Unrated covered bonds: derive from issuer institution RW (CRR Art. 129(5)).
        .when(
            uc.str.contains("COVERED_BOND", literal=True)
            & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
        )
        .then(_crr_unrated_cb_rw_expr())
        # CRR Art. 128 (high-risk items, 150%) was OMITTED from UK onshored CRR
        # by SI 2021/1078 reg. 6(3)(a) with effect from 1 January 2022. Exposures
        # that map to HIGH_RISK under the entity-type table therefore fall through
        # to the OTHER (residual) class at 100% under UK CRR. The 150% treatment
        # is re-introduced under PRA PS1/26 Basel 3.1 — see
        # _apply_b31_risk_weight_overrides.
        # Other Items (Art. 134): sub-type-specific risk weights.
        .when(
            (uc == "OTHER")
            & (pl.col("cp_entity_type").fill_null("").is_in(["other_cash", "other_gold"]))
        )
        .then(pl.lit(_SA_SHARED_RW["other_cash"]))
        .when(
            (uc == "OTHER")
            & (pl.col("cp_entity_type").fill_null("") == "other_items_in_collection")
        )
        .then(pl.lit(_SA_SHARED_RW["other_collection"]))
        .when((uc == "OTHER") & (pl.col("cp_entity_type").fill_null("") == "other_residual_lease"))
        .then(pl.lit(1.0) / pl.col("residual_maturity_years").fill_null(1.0).clip(lower_bound=1.0))
        .when(uc == "OTHER")
        .then(pl.lit(_SA_SHARED_RW["other_default"]))
        # Equity (Art. 133(2)): flat 100%.
        .when(uc == "EQUITY")
        .then(pl.lit(_SA_CRR_RW["equity"]))
        .otherwise(pl.col("risk_weight").fill_null(1.0))
        .alias("risk_weight")
    )
    return exposures

CRR Art. 135 — Use of credit assessments by ECAIs

attach_counterparty_rating — src/rwa_calc/engine/stages/hierarchy/enrich.py:101
@cites("CRR Art. 135")
@cites("CRR Art. 136")
@cites("CRR Art. 138")
@cites("CRR Art. 139")
def attach_counterparty_rating(
    exposures: pl.LazyFrame,
    counterparty_lookup: CounterpartyLookup,
) -> pl.LazyFrame:
    """Join counterparty rating fields onto every exposure row.

    ``cqs`` and ``pd`` are used by SA / IRB calculators; ``internal_pd`` is
    used by the classifier to gate IRB approach on internal-rating
    availability; ``external_cqs`` is carried for audit trail; ``model_id``
    (sourced from ``internal_model_id`` via the rating inheritance pipeline)
    links to model_permissions for per-model approach gating.
    """
    cp_schema = set(counterparty_lookup.counterparties.collect_schema().names())
    cp_select = [pl.col("counterparty_reference"), pl.col("cqs"), pl.col("pd")]
    if "internal_pd" in cp_schema:
        cp_select.append(pl.col("internal_pd"))
    if "external_cqs" in cp_schema:
        cp_select.append(pl.col("external_cqs"))
    if "external_rating_is_issue_specific" in cp_schema:
        cp_select.append(pl.col("external_rating_is_issue_specific"))
    if "internal_model_id" in cp_schema:
        cp_select.append(pl.col("internal_model_id"))

    exposures = exposures.join(
        counterparty_lookup.counterparties.select(cp_select),
        on="counterparty_reference",
        how="left",
    )

    # Ensure internal_pd, external_cqs, and model_id always exist for classifier
    rating_defaults = []
    if "internal_pd" not in cp_schema:
        rating_defaults.append(pl.lit(None).cast(pl.Float64).alias("internal_pd"))
    if "external_cqs" not in cp_schema:
        rating_defaults.append(pl.lit(None).cast(pl.Int8).alias("external_cqs"))
    # PRA PS1/26 Art. 139(2B): default provenance flag when no external rating
    # resolved — treat as issue-specific (legacy behaviour, no disapplication).
    if "external_rating_is_issue_specific" not in cp_schema:
        rating_defaults.append(
            pl.lit(True).cast(pl.Boolean).alias("external_rating_is_issue_specific")
        )
    if rating_defaults:
        exposures = exposures.with_columns(rating_defaults)

    # model_id: sourced from internal_model_id (rating inheritance pipeline).
    # We know internal_model_id was joined from cp_schema above.
    if "internal_model_id" in cp_schema:
        return exposures.with_columns(pl.col("internal_model_id").alias("model_id")).drop(
            "internal_model_id"
        )
    return exposures.with_columns(pl.lit(None).cast(pl.String).alias("model_id"))

CRR Art. 136 — Mapping of ECAI's credit assessments

attach_counterparty_rating — src/rwa_calc/engine/stages/hierarchy/enrich.py:102
@cites("CRR Art. 135")
@cites("CRR Art. 136")
@cites("CRR Art. 138")
@cites("CRR Art. 139")
def attach_counterparty_rating(
    exposures: pl.LazyFrame,
    counterparty_lookup: CounterpartyLookup,
) -> pl.LazyFrame:
    """Join counterparty rating fields onto every exposure row.

    ``cqs`` and ``pd`` are used by SA / IRB calculators; ``internal_pd`` is
    used by the classifier to gate IRB approach on internal-rating
    availability; ``external_cqs`` is carried for audit trail; ``model_id``
    (sourced from ``internal_model_id`` via the rating inheritance pipeline)
    links to model_permissions for per-model approach gating.
    """
    cp_schema = set(counterparty_lookup.counterparties.collect_schema().names())
    cp_select = [pl.col("counterparty_reference"), pl.col("cqs"), pl.col("pd")]
    if "internal_pd" in cp_schema:
        cp_select.append(pl.col("internal_pd"))
    if "external_cqs" in cp_schema:
        cp_select.append(pl.col("external_cqs"))
    if "external_rating_is_issue_specific" in cp_schema:
        cp_select.append(pl.col("external_rating_is_issue_specific"))
    if "internal_model_id" in cp_schema:
        cp_select.append(pl.col("internal_model_id"))

    exposures = exposures.join(
        counterparty_lookup.counterparties.select(cp_select),
        on="counterparty_reference",
        how="left",
    )

    # Ensure internal_pd, external_cqs, and model_id always exist for classifier
    rating_defaults = []
    if "internal_pd" not in cp_schema:
        rating_defaults.append(pl.lit(None).cast(pl.Float64).alias("internal_pd"))
    if "external_cqs" not in cp_schema:
        rating_defaults.append(pl.lit(None).cast(pl.Int8).alias("external_cqs"))
    # PRA PS1/26 Art. 139(2B): default provenance flag when no external rating
    # resolved — treat as issue-specific (legacy behaviour, no disapplication).
    if "external_rating_is_issue_specific" not in cp_schema:
        rating_defaults.append(
            pl.lit(True).cast(pl.Boolean).alias("external_rating_is_issue_specific")
        )
    if rating_defaults:
        exposures = exposures.with_columns(rating_defaults)

    # model_id: sourced from internal_model_id (rating inheritance pipeline).
    # We know internal_model_id was joined from cp_schema above.
    if "internal_model_id" in cp_schema:
        return exposures.with_columns(pl.col("internal_model_id").alias("model_id")).drop(
            "internal_model_id"
        )
    return exposures.with_columns(pl.lit(None).cast(pl.String).alias("model_id"))

CRR Art. 137 — Use of credit assessments by export credit agencies

_eca_meip_rw_expr — src/rwa_calc/engine/sa/risk_weights.py:416
@cites("CRR Art. 137")
def _eca_meip_rw_expr() -> pl.Expr:
    """Build Polars expression mapping ``cp_eca_score`` (0-7) to sovereign RW.

    Maps directly to the rulepack ``eca_meip_risk_weights`` table per CRR
    Art. 137(2) Table 9 —
    no intermediate CQS step. When ``cp_eca_score`` is null or out of range
    the expression returns null so callers can defer to the standard
    Art. 114 unrated fallback.
    """
    col = pl.col("cp_eca_score")
    expr = pl.when(col == 0).then(pl.lit(_ECA_MEIP_RW[0]))
    for score in range(1, 8):
        expr = expr.when(col == score).then(pl.lit(_ECA_MEIP_RW[score]))
    return expr.otherwise(pl.lit(None, dtype=pl.Float64))
_apply_b31_risk_weight_overrides — src/rwa_calc/engine/sa/risk_weights.py:991
@cites("CRR Art. 134")
@cites("CRR Art. 137")
def _apply_b31_risk_weight_overrides(
    exposures: pl.LazyFrame,
    uc: pl.Expr,
    is_domestic_currency: pl.Expr,
    config: CalculationConfig,
) -> pl.LazyFrame:
    """Apply Basel 3.1 class-specific risk-weight overrides (CRE20, PRA PS1/26)."""
    # Save the CQS-based risk weight before overrides — needed for the
    # Basel 3.1 general CRE min(60%, counterparty_rw) logic (CRE20.85).
    exposures = exposures.with_columns(
        pl.col("risk_weight").fill_null(1.0).alias("_cqs_risk_weight")
    )

    # Build the override chain in regulatory precedence order:
    #   CGCB / QCCP / subordinated debt   [early overrides, before RE/CQS]
    #   real estate                        (ADC, other-RE, residential, commercial)
    #   sovereign-like                     (PSE, RGLA)
    #   MDB / IO
    #   institution maturity               (ECRA short, SCRA short, SCRA long)
    #   corporate / retail / misc          (IG, SME, SL, QRRE, payroll, retail, ...)
    #   covered bond / high risk / other items / equity
    chain = (
        pl.when(pl.col("risk_type") == _SETTLEMENT_FAILED_TRADE_RISK_TYPE)  # P8.43 failed trade
        .then(pl.lit(_OWN_FUNDS_TO_RWA_FACTOR))
        .when(
            pl.col("risk_type") == _CCR_DEFAULT_FUND_RISK_TYPE
        )  # P8.49 default fund (Art. 308/309)
        .then(pl.lit(_OWN_FUNDS_TO_RWA_FACTOR))
        .when(uc.str.contains("CENTRAL_GOVT", literal=True) & is_domestic_currency)
        .then(pl.lit(0.0))
        # Art. 137(1)-(2) Table 9: nominated ECA / MEIP score → direct sovereign
        # RW when no ECAI rating is present. Takes precedence over the Art. 114
        # unrated 100% fallback but not over the Art. 114(4)/(7) domestic 0%.
        # Identical to the CRR arm — MEIP risk weights are unchanged under PS1/26.
        .when(
            uc.str.contains("CENTRAL_GOVT", literal=True)
            & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
            & pl.col("cp_eca_score").is_not_null()
        )
        .then(_eca_meip_rw_expr())
        # QCCP trade exposures (CRR Art. 306, CRE54.14-15). The 2%/4% pin is
        # for QUALIFYING CCPs only (Art. 272 Def (88)): an explicit
        # cp_is_qccp=False demotes a ``ccp`` entity_type to the standard
        # institution ladder (Art. 107(2)(a)). An absent flag is treated as
        # qualifying so legacy ``ccp`` rows keep the prescribed weight.
        .when((pl.col("cp_entity_type") == "ccp") & pl.col("cp_is_qccp").fill_null(True))
        .then(
            pl.when(pl.col("cp_is_ccp_client_cleared").fill_null(False))
            .then(pl.lit(_SA_SHARED_RW["qccp_client_cleared"]))
            .otherwise(pl.lit(_SA_SHARED_RW["qccp_proprietary"]))
        )
        # Subordinated debt: flat 150% (CRE20.47) — overrides all CQS-based
        # weights for institution + corporate.
        .when(
            (pl.col("seniority").fill_null("senior") == "subordinated")
            & (
                uc.str.contains("INSTITUTION", literal=True)
                | uc.str.contains("CORPORATE", literal=True)
            )
        )
        .then(pl.lit(_SA_B31_RW["sub_debt"]))
    )

    chain = _b31_append_real_estate_branches(chain, uc)

    # Sovereign-like treatments (PSE then RGLA).
    chain = (
        # PSE short-term (Art. 116(3)): original maturity <= 3m -> 20% flat.
        # Art. 116(3) keys on ORIGINAL maturity — a seasoned long-dated PSE
        # bond with short residual does not qualify.
        chain.when(
            (uc == "PSE")
            & pl.col("original_maturity_years").is_not_null()
            & (pl.col("original_maturity_years") <= 0.25)
        )
        .then(pl.lit(_SA_SHARED_RW["pse_short_term"]))
        # PSE unrated: sovereign-derived RW lookup (Art. 116(1), Table 2).
        # Maps cp_sovereign_cqs -> RW; falls back to 100% when sovereign
        # CQS is unknown.
        .when((uc == "PSE") & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0)))
        .then(
            _sovereign_derived_rw_expr(
                PSE_RISK_WEIGHTS_SOVEREIGN_DERIVED,
                _SA_SHARED_RW["pse_unrated"],
            )
        )
        # RGLA UK devolved govt -> 0% (PRA designation).
        .when(
            (uc == "RGLA")
            & (pl.col("cp_entity_type").fill_null("") == "rgla_sovereign")
            & (pl.col("cp_country_code") == "GB")
        )
        .then(pl.lit(_SA_SHARED_RW["rgla_uk_devolved"]))
        # RGLA domestic currency -> 20% (Art. 115(5)).
        .when((uc == "RGLA") & is_domestic_currency)
        .then(pl.lit(_SA_SHARED_RW["rgla_domestic"]))
        # RGLA unrated non-domestic: sovereign-derived (Art. 115(1)(a)
        # Table 1A). Maps cp_sovereign_cqs -> RW; falls back to 100% when
        # sovereign CQS is unknown.
        .when((uc == "RGLA") & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0)))
        .then(
            _sovereign_derived_rw_expr(
                RGLA_RISK_WEIGHTS_SOVEREIGN_DERIVED,
                _SA_SHARED_RW["rgla_unrated"],
            )
        )
        # International Organisation -> 0% (Art. 118).
        .when(uc == "INTERNATIONAL_ORGANISATION")
        .then(pl.lit(_SA_SHARED_RW["io"]))
        # Named MDB -> 0% (Art. 117(2)).
        .when((uc == "MDB") & (pl.col("cp_entity_type").fill_null("") == "mdb_named"))
        .then(pl.lit(_SA_SHARED_RW["mdb_named"]))
        # Unrated non-named MDB -> 50% (Art. 117(1), Table 2B).
        .when((uc == "MDB") & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0)))
        .then(pl.lit(_SA_SHARED_RW["mdb_unrated"]))
    )

    chain = _b31_append_institution_maturity_branches(chain, uc)
    chain = _b31_append_corporate_maturity_branches(chain, uc)
    chain = _b31_append_high_risk_branch(chain, uc)

    # Corporate / retail / misc tail of the chain.
    is_unrated_corporate = (
        uc.str.contains("CORPORATE", literal=True)
        & ~uc.str.contains("SME", literal=True)
        & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
    )
    chain = (
        chain
        # Investment-grade assessment (Art. 122(6)/(8)) — only active under
        # use_investment_grade_assessment. IG -> 65%, non-IG -> 135%.
        .when(
            pl.lit(config.use_investment_grade_assessment)
            & is_unrated_corporate
            & (pl.col("cp_is_investment_grade").fill_null(False) == True)  # noqa: E712
        )
        .then(pl.lit(_SA_B31_RW["corporate_ig"]))
        .when(
            pl.lit(config.use_investment_grade_assessment)
            & is_unrated_corporate
            & (pl.col("cp_is_investment_grade").fill_null(False) != True)  # noqa: E712
        )
        .then(pl.lit(_SA_B31_RW["corporate_nig"]))
        # SME managed as retail: 75% (Art. 123, aggregated <= EUR 1m).
        .when(
            uc.str.contains("SME", literal=True)
            & (pl.col("cp_is_managed_as_retail") == True)  # noqa: E712
            & (pl.col("qualifies_as_retail") == True)  # noqa: E712
        )
        .then(pl.lit(_SA_SHARED_RW["retail"]))
        # SA Specialised Lending — unrated only (Art. 122A-122B). Rated SL
        # exposures use the corporate CQS table (Art. 122A(3)).
        .when(
            (
                uc.str.contains("SPECIALISED", literal=True)
                | (pl.col("sl_type").fill_null("").str.len_chars() > 0)
            )
            & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
        )
        .then(b31_sa_sl_rw_expr())
        # Corporate SME: 85% — unrated only (Art. 122(11)). A rated SME
        # (CQS 1-6) keeps its Art. 122(2) Table-6 weight from the rw_table join.
        .when(
            uc.str.contains("CORPORATE", literal=True)
            & uc.str.contains("SME", literal=True)
            & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
        )
        .then(pl.lit(_SA_B31_RW["corporate_sme"]))
    )

    # Retail-class branches (Art. 123).
    chain = _b31_append_retail_branches(chain, uc)

    exposures = exposures.with_columns(
        chain
        # Unrated covered bonds: derive from issuer institution RW
        # (Art. 129(5)). ECRA (rated issuer, cp_institution_cqs) checked
        # first, then SCRA (unrated issuer, cp_scra_grade) as fallback.
        .when(
            uc.str.contains("COVERED_BOND", literal=True)
            & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
        )
        .then(_b31_unrated_cb_rw_expr())
        # Other Items (Art. 134): sub-type-specific risk weights.
        .when(
            (uc == "OTHER")
            & (pl.col("cp_entity_type").fill_null("").is_in(["other_cash", "other_gold"]))
        )
        .then(pl.lit(_SA_SHARED_RW["other_cash"]))
        .when(
            (uc == "OTHER")
            & (pl.col("cp_entity_type").fill_null("") == "other_items_in_collection")
        )
        .then(pl.lit(_SA_SHARED_RW["other_collection"]))
        .when((uc == "OTHER") & (pl.col("cp_entity_type").fill_null("") == "other_residual_lease"))
        .then(pl.lit(1.0) / pl.col("residual_maturity_years").fill_null(1.0).clip(lower_bound=1.0))
        .when(uc == "OTHER")
        .then(pl.lit(_SA_SHARED_RW["other_default"]))
        # Equity (Art. 133(3)): 250% — full equity treatment (CIU,
        # transitional floor) lives in the dedicated equity table.
        .when(uc == "EQUITY")
        .then(pl.lit(_SA_B31_RW["equity"]))
        .otherwise(pl.col("risk_weight").fill_null(1.0))
        .alias("risk_weight")
    )
    return exposures
_apply_crr_risk_weight_overrides — src/rwa_calc/engine/sa/risk_weights.py:1201
@cites("CRR Art. 134")
@cites("CRR Art. 137")
def _apply_crr_risk_weight_overrides(
    exposures: pl.LazyFrame,
    uc: pl.Expr,
    is_domestic_currency: pl.Expr,
) -> pl.LazyFrame:
    """Apply CRR class-specific risk-weight overrides (Art. 112-134)."""
    chain = (
        pl.when(pl.col("risk_type") == _SETTLEMENT_FAILED_TRADE_RISK_TYPE)  # P8.43 failed trade
        .then(pl.lit(_OWN_FUNDS_TO_RWA_FACTOR))
        .when(
            pl.col("risk_type") == _CCR_DEFAULT_FUND_RISK_TYPE
        )  # P8.49 default fund (Art. 308/309)
        .then(pl.lit(_OWN_FUNDS_TO_RWA_FACTOR))
        # Art. 114(4)/(7): Domestic CGCB -> 0% RW (overrides all CQS).
        .when(uc.str.contains("CENTRAL_GOVT", literal=True) & is_domestic_currency)
        .then(pl.lit(0.0))
        # Art. 137(1)-(2) Table 9: nominated ECA / MEIP score → direct sovereign
        # RW when no ECAI rating is present. Takes precedence over the Art. 114
        # unrated 100% fallback but not over the Art. 114(4)/(7) domestic 0%.
        .when(
            uc.str.contains("CENTRAL_GOVT", literal=True)
            & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
            & pl.col("cp_eca_score").is_not_null()
        )
        .then(_eca_meip_rw_expr())
        # QCCP trade exposures (CRR Art. 306, CRE54.14-15). The 2%/4% pin is
        # for QUALIFYING CCPs only (Art. 272 Def (88)): an explicit
        # cp_is_qccp=False demotes a ``ccp`` entity_type to the standard
        # institution ladder (Art. 107(2)(a)). An absent flag is treated as
        # qualifying so legacy ``ccp`` rows keep the prescribed weight.
        .when((pl.col("cp_entity_type") == "ccp") & pl.col("cp_is_qccp").fill_null(True))
        .then(
            pl.when(pl.col("cp_is_ccp_client_cleared").fill_null(False))
            .then(pl.lit(_SA_SHARED_RW["qccp_client_cleared"]))
            .otherwise(pl.lit(_SA_SHARED_RW["qccp_proprietary"]))
        )
    )

    chain = _crr_append_real_estate_branches(chain, uc)

    # SME / retail branches.
    chain = (
        # SME managed as retail: 75% (CRR Art. 123, aggregated <= EUR 1m).
        chain.when(
            uc.str.contains("SME", literal=True)
            & (pl.col("cp_is_managed_as_retail") == True)  # noqa: E712
            & (pl.col("qualifies_as_retail") == True)  # noqa: E712
        )
        .then(pl.lit(_SA_SHARED_RW["retail"]))
        # Corporate SME: 100% — unrated only (Art. 122). A rated SME (CQS 1-6)
        # keeps its Art. 122 CQS-table weight from the rw_table join; SME relief
        # is delivered separately via the Art. 501 supporting factor.
        .when(
            uc.str.contains("CORPORATE", literal=True)
            & uc.str.contains("SME", literal=True)
            & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
        )
        .then(pl.lit(_SA_CRR_RW["corporate_sme"]))
    )

    # Retail-class branches (Art. 123).
    chain = _crr_append_retail_branches(chain, uc)

    # Sovereign-like (PSE, RGLA, MDB, IO).
    chain = (
        # PSE short-term (Art. 116(3)): original maturity <= 3m -> 20%.
        chain.when(
            (uc == "PSE")
            & pl.col("original_maturity_years").is_not_null()
            & (pl.col("original_maturity_years") <= 0.25)
        )
        .then(pl.lit(_SA_SHARED_RW["pse_short_term"]))
        # PSE unrated: sovereign-derived RW lookup (Art. 116(1), Table 2).
        # Maps cp_sovereign_cqs -> RW; falls back to 100% when sovereign
        # CQS is unknown.
        .when((uc == "PSE") & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0)))
        .then(
            _sovereign_derived_rw_expr(
                PSE_RISK_WEIGHTS_SOVEREIGN_DERIVED,
                _SA_SHARED_RW["pse_unrated"],
            )
        )
        # RGLA UK devolved govt -> 0% (PRA designation).
        .when(
            (uc == "RGLA")
            & (pl.col("cp_entity_type").fill_null("") == "rgla_sovereign")
            & (pl.col("cp_country_code") == "GB")
        )
        .then(pl.lit(_SA_SHARED_RW["rgla_uk_devolved"]))
        # RGLA domestic currency -> 20% (Art. 115(5)).
        .when((uc == "RGLA") & is_domestic_currency)
        .then(pl.lit(_SA_SHARED_RW["rgla_domestic"]))
        # RGLA unrated non-domestic: sovereign-derived (Art. 115(1)(a)
        # Table 1A). Maps cp_sovereign_cqs -> RW; falls back to 100% when
        # sovereign CQS is unknown.
        .when((uc == "RGLA") & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0)))
        .then(
            _sovereign_derived_rw_expr(
                RGLA_RISK_WEIGHTS_SOVEREIGN_DERIVED,
                _SA_SHARED_RW["rgla_unrated"],
            )
        )
        # International Organisation -> 0% (Art. 118).
        .when(uc == "INTERNATIONAL_ORGANISATION")
        .then(pl.lit(_SA_SHARED_RW["io"]))
        # Named MDB -> 0% (Art. 117(2)).
        .when((uc == "MDB") & (pl.col("cp_entity_type").fill_null("") == "mdb_named"))
        .then(pl.lit(_SA_SHARED_RW["mdb_named"]))
        # CRR Art. 117(1): non-named MDBs are treated as institutions and use
        # the institution risk weight tables (Art. 120 Table 3 if rated, Art.
        # 121 Table 5 sovereign-derived if unrated). The dedicated Basel 3.1
        # Table 2B path (PRA PS1/26 Art. 117(1)(a)) does NOT apply under CRR.
        # The Art. 119(2)/120(2)/121(3) short-term carve-outs are excluded for
        # MDBs by Art. 117(1), so no short-term branch is consulted here.
        # Rated non-named MDB: Art. 120 Table 3 (institution own CQS).
        .when((uc == "MDB") & pl.col("cqs").is_not_null() & (pl.col("cqs") > 0))
        .then(build_institution_guarantor_rw_expr("cqs", is_basel_3_1=False))
        # Unrated non-named MDB: Art. 121 Table 5 sovereign-derived; Art. 121
        # fallback (100%) when the MDB's home sovereign CQS is unknown.
        .when((uc == "MDB") & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0)))
        .then(
            _sovereign_derived_rw_expr(
                INSTITUTION_RISK_WEIGHTS_SOVEREIGN_DERIVED,
                float(INSTITUTION_RISK_WEIGHTS_CRR[CQS.UNRATED]),
            )
        )
    )

    chain = _crr_append_institution_maturity_branches(chain, uc)

    # Covered bond / high risk / other items / equity tail.
    exposures = exposures.with_columns(
        chain
        # Unrated covered bonds: derive from issuer institution RW (CRR Art. 129(5)).
        .when(
            uc.str.contains("COVERED_BOND", literal=True)
            & (pl.col("cqs").is_null() | (pl.col("cqs") <= 0))
        )
        .then(_crr_unrated_cb_rw_expr())
        # CRR Art. 128 (high-risk items, 150%) was OMITTED from UK onshored CRR
        # by SI 2021/1078 reg. 6(3)(a) with effect from 1 January 2022. Exposures
        # that map to HIGH_RISK under the entity-type table therefore fall through
        # to the OTHER (residual) class at 100% under UK CRR. The 150% treatment
        # is re-introduced under PRA PS1/26 Basel 3.1 — see
        # _apply_b31_risk_weight_overrides.
        # Other Items (Art. 134): sub-type-specific risk weights.
        .when(
            (uc == "OTHER")
            & (pl.col("cp_entity_type").fill_null("").is_in(["other_cash", "other_gold"]))
        )
        .then(pl.lit(_SA_SHARED_RW["other_cash"]))
        .when(
            (uc == "OTHER")
            & (pl.col("cp_entity_type").fill_null("") == "other_items_in_collection")
        )
        .then(pl.lit(_SA_SHARED_RW["other_collection"]))
        .when((uc == "OTHER") & (pl.col("cp_entity_type").fill_null("") == "other_residual_lease"))
        .then(pl.lit(1.0) / pl.col("residual_maturity_years").fill_null(1.0).clip(lower_bound=1.0))
        .when(uc == "OTHER")
        .then(pl.lit(_SA_SHARED_RW["other_default"]))
        # Equity (Art. 133(2)): flat 100%.
        .when(uc == "EQUITY")
        .then(pl.lit(_SA_CRR_RW["equity"]))
        .otherwise(pl.col("risk_weight").fill_null(1.0))
        .alias("risk_weight")
    )
    return exposures

CRR Art. 138 — General requirements

attach_counterparty_rating — src/rwa_calc/engine/stages/hierarchy/enrich.py:103
@cites("CRR Art. 135")
@cites("CRR Art. 136")
@cites("CRR Art. 138")
@cites("CRR Art. 139")
def attach_counterparty_rating(
    exposures: pl.LazyFrame,
    counterparty_lookup: CounterpartyLookup,
) -> pl.LazyFrame:
    """Join counterparty rating fields onto every exposure row.

    ``cqs`` and ``pd`` are used by SA / IRB calculators; ``internal_pd`` is
    used by the classifier to gate IRB approach on internal-rating
    availability; ``external_cqs`` is carried for audit trail; ``model_id``
    (sourced from ``internal_model_id`` via the rating inheritance pipeline)
    links to model_permissions for per-model approach gating.
    """
    cp_schema = set(counterparty_lookup.counterparties.collect_schema().names())
    cp_select = [pl.col("counterparty_reference"), pl.col("cqs"), pl.col("pd")]
    if "internal_pd" in cp_schema:
        cp_select.append(pl.col("internal_pd"))
    if "external_cqs" in cp_schema:
        cp_select.append(pl.col("external_cqs"))
    if "external_rating_is_issue_specific" in cp_schema:
        cp_select.append(pl.col("external_rating_is_issue_specific"))
    if "internal_model_id" in cp_schema:
        cp_select.append(pl.col("internal_model_id"))

    exposures = exposures.join(
        counterparty_lookup.counterparties.select(cp_select),
        on="counterparty_reference",
        how="left",
    )

    # Ensure internal_pd, external_cqs, and model_id always exist for classifier
    rating_defaults = []
    if "internal_pd" not in cp_schema:
        rating_defaults.append(pl.lit(None).cast(pl.Float64).alias("internal_pd"))
    if "external_cqs" not in cp_schema:
        rating_defaults.append(pl.lit(None).cast(pl.Int8).alias("external_cqs"))
    # PRA PS1/26 Art. 139(2B): default provenance flag when no external rating
    # resolved — treat as issue-specific (legacy behaviour, no disapplication).
    if "external_rating_is_issue_specific" not in cp_schema:
        rating_defaults.append(
            pl.lit(True).cast(pl.Boolean).alias("external_rating_is_issue_specific")
        )
    if rating_defaults:
        exposures = exposures.with_columns(rating_defaults)

    # model_id: sourced from internal_model_id (rating inheritance pipeline).
    # We know internal_model_id was joined from cp_schema above.
    if "internal_model_id" in cp_schema:
        return exposures.with_columns(pl.col("internal_model_id").alias("model_id")).drop(
            "internal_model_id"
        )
    return exposures.with_columns(pl.lit(None).cast(pl.String).alias("model_id"))

CRR Art. 139 — Issuer and issue credit assessment

attach_counterparty_rating — src/rwa_calc/engine/stages/hierarchy/enrich.py:104
@cites("CRR Art. 135")
@cites("CRR Art. 136")
@cites("CRR Art. 138")
@cites("CRR Art. 139")
def attach_counterparty_rating(
    exposures: pl.LazyFrame,
    counterparty_lookup: CounterpartyLookup,
) -> pl.LazyFrame:
    """Join counterparty rating fields onto every exposure row.

    ``cqs`` and ``pd`` are used by SA / IRB calculators; ``internal_pd`` is
    used by the classifier to gate IRB approach on internal-rating
    availability; ``external_cqs`` is carried for audit trail; ``model_id``
    (sourced from ``internal_model_id`` via the rating inheritance pipeline)
    links to model_permissions for per-model approach gating.
    """
    cp_schema = set(counterparty_lookup.counterparties.collect_schema().names())
    cp_select = [pl.col("counterparty_reference"), pl.col("cqs"), pl.col("pd")]
    if "internal_pd" in cp_schema:
        cp_select.append(pl.col("internal_pd"))
    if "external_cqs" in cp_schema:
        cp_select.append(pl.col("external_cqs"))
    if "external_rating_is_issue_specific" in cp_schema:
        cp_select.append(pl.col("external_rating_is_issue_specific"))
    if "internal_model_id" in cp_schema:
        cp_select.append(pl.col("internal_model_id"))

    exposures = exposures.join(
        counterparty_lookup.counterparties.select(cp_select),
        on="counterparty_reference",
        how="left",
    )

    # Ensure internal_pd, external_cqs, and model_id always exist for classifier
    rating_defaults = []
    if "internal_pd" not in cp_schema:
        rating_defaults.append(pl.lit(None).cast(pl.Float64).alias("internal_pd"))
    if "external_cqs" not in cp_schema:
        rating_defaults.append(pl.lit(None).cast(pl.Int8).alias("external_cqs"))
    # PRA PS1/26 Art. 139(2B): default provenance flag when no external rating
    # resolved — treat as issue-specific (legacy behaviour, no disapplication).
    if "external_rating_is_issue_specific" not in cp_schema:
        rating_defaults.append(
            pl.lit(True).cast(pl.Boolean).alias("external_rating_is_issue_specific")
        )
    if rating_defaults:
        exposures = exposures.with_columns(rating_defaults)

    # model_id: sourced from internal_model_id (rating inheritance pipeline).
    # We know internal_model_id was joined from cp_schema above.
    if "internal_model_id" in cp_schema:
        return exposures.with_columns(pl.col("internal_model_id").alias("model_id")).drop(
            "internal_model_id"
        )
    return exposures.with_columns(pl.lit(None).cast(pl.String).alias("model_id"))

CRR Art. 140 — Long-term and short-term credit assessments

apply_short_term_rating_override — src/rwa_calc/engine/stages/hierarchy/enrich.py:159
@cites("CRR Art. 131")
@cites("CRR Art. 140")
def apply_short_term_rating_override(
    exposures: pl.LazyFrame,
    ratings: pl.LazyFrame | None,
) -> pl.LazyFrame:
    """Apply per-exposure short-term rating override.

    Short-term ECAI assessments under PRA PS1/26 Art. 120(2B) Table 4A and
    Art. 122(3) Table 6A are issue-specific — each rating row attaches to a
    single exposure via ``(scope_type, scope_id)``. When a short-term rating
    row matches an exposure, its ``cqs`` overrides the counterparty-level
    rating attached by ``attach_counterparty_rating`` and the derived
    ``has_short_term_ecai`` flag is set to True, signalling the SA engine to
    route via Table 4A / Table 6A.

    Scope matching:

    - ``scope_type='facility'``  -> matches the source facility's drawn loans,
      its synthetic ``facility_undrawn`` row, and any descendant exposure via
      ``parent_facility_reference`` / ``root_facility_reference``.
    - ``scope_type='loan'``      -> matches the loan exposure with the same
      ``exposure_reference`` and ``exposure_type='loan'``.
    - ``scope_type='contingent'`` -> matches the contingent exposure with the
      same ``exposure_reference`` and ``exposure_type='contingent'``.

    Ties (multiple short-term ratings for the same exposure) are resolved by
    picking the row with the lowest CQS, breaking ties by latest
    ``rating_date``. This mirrors the external best-rating selection in
    ``ratings.build_rating_inheritance_lazy``.

    Always returns ``exposures`` augmented with a ``has_short_term_ecai``
    boolean column (False when no override matched).
    """
    st_ratings = _prepare_short_term_lookup(ratings)
    if st_ratings is None:
        return exposures.with_columns(pl.lit(False).alias("has_short_term_ecai"))

    exp_schema = set(exposures.collect_schema().names())
    match_branches = _build_short_term_match_branches(exp_schema)

    # Track which scope branches actually produce a match so we can
    # coalesce the resulting cqs in priority order: loan > contingent >
    # facility (most specific wins).
    joined_scopes: list[str] = []
    for scope, key_expr in match_branches:
        scope_lookup = st_ratings.filter(pl.col("_st_scope_type") == scope).select(
            [
                pl.col("_st_cp"),
                pl.col("_st_scope_id"),
                pl.col("_st_cqs").alias(f"_st_{scope}_cqs"),
            ]
        )
        exposures = exposures.with_columns(key_expr.alias(f"_match_key_{scope}"))
        exposures = exposures.join(
            scope_lookup,
            left_on=["counterparty_reference", f"_match_key_{scope}"],
            right_on=["_st_cp", "_st_scope_id"],
            how="left",
        ).drop(f"_match_key_{scope}")
        joined_scopes.append(scope)

    if not joined_scopes:
        return exposures.with_columns(pl.lit(False).alias("has_short_term_ecai"))

    # Coalesce in priority order: loan > contingent > facility (most
    # specific scope wins).
    priority = ["loan", "contingent", "facility"]
    ordered = [s for s in priority if s in joined_scopes]
    st_cqs_expr = pl.coalesce([pl.col(f"_st_{s}_cqs") for s in ordered])

    # Override: when the short-term cqs is non-null, replace the cqs column
    # and set has_short_term_ecai=True. SA Tables 4A / 6A are keyed off cqs
    # only — rating_agency / rating_value are audit columns added later by
    # the classifier and intentionally not overridden here.
    has_st = st_cqs_expr.is_not_null()
    exposures = exposures.with_columns(
        [
            has_st.alias("has_short_term_ecai"),
            pl.when(has_st).then(st_cqs_expr).otherwise(pl.col("cqs")).cast(pl.Int8).alias("cqs"),
        ]
    )
    return exposures.drop([f"_st_{s}_cqs" for s in joined_scopes])

CRR Art. 141 — Domestic and foreign currency items

build_eu_domestic_currency_expr — src/rwa_calc/engine/eu_sovereign.py:47
@cites("CRR Art. 114")
@cites("CRR Art. 141")
def build_eu_domestic_currency_expr(
    country_col: str,
    currency_col: str | pl.Expr = "currency",
) -> pl.Expr:
    """
    Build a Polars expression that checks if an exposure is to an EU member
    state's central government/central bank denominated in that state's
    domestic currency.

    Uses replace_strict to map country code → domestic currency, then compares
    with the exposure denomination currency.

    Args:
        country_col: Column name containing the ISO country code
        currency_col: Column name (str) or Polars expression for the
            exposure's denomination currency. A string is wrapped in
            ``pl.col(...)``. Callers operating on a post-FX-conversion
            LazyFrame should pass ``denomination_currency_expr(...)`` so the
            original (pre-conversion) currency is compared — not the reporting
            currency.

    Returns:
        Boolean Polars expression: True when country is EU and currency matches
        that country's domestic currency.
    """
    currency_expr = pl.col(currency_col) if isinstance(currency_col, str) else currency_col
    return (
        pl.col(country_col)
        .fill_null("")
        .replace_strict(_EU_COUNTRY_DOMESTIC_CURRENCY, default=None)
        .eq(currency_expr)
    )

CRR Art. 142 — Definitions for the IRB approach

Definitions only — no calculation path. The terms are realised in src/rwa_calc/domain/enums.py (e.g. ExposureClass, ApproachType) and src/rwa_calc/data/schemas.py.

CRR Art. 143 — Permission to use the IRB Approach

resolve_model_permissions — src/rwa_calc/engine/stages/classify/permissions.py:54
@cites("CRR Art. 143")
@cites("CRR Art. 148")
@cites("CRR Art. 150")
def resolve_model_permissions(
    exposures: pl.LazyFrame,
    model_permissions: pl.LazyFrame,
) -> pl.LazyFrame:
    """
    Join exposures with model_permissions to produce per-row permission flags.

    model_id originates on internal ratings and is propagated to exposures by
    the rating inheritance pipeline. This method resolves which IRB approach each
    exposure is permitted to use based on:
    - model_id match (rating's model_id must exist in model_permissions)
    - exposure_class match
    - Geography filter: country_codes is null OR cp_country_code is in the list
    - Book code exclusion: excluded_book_codes is null OR book_code NOT in the list

    Priority: AIRB > FIRB. If a model has both, AIRB wins for exposures that
    also have modelled LGD; otherwise FIRB is used if the exposure has internal_pd.

    Sets: model_airb_permitted (bool), model_firb_permitted (bool),
          model_slotting_permitted (bool)

    Exposures without a model_id get all flags as False (→ SA fallback).

    Synthetic CCR rows reach this stage with ``model_id = null`` because the
    rating-inheritance attach that renames ``internal_model_id`` -> ``model_id``
    only runs over hierarchy-resolved lending rows. When the counterparty
    lookup carried an ``internal_model_id`` it was surfaced as
    ``cp_internal_model_id`` by ``_add_counterparty_attributes``; coalescing it
    into ``model_id`` here lets an IRB-permissioned counterparty's CCR
    derivative exposure resolve a model permission instead of falling back to
    SA. The coalesce is a no-op for lending rows whose ``model_id`` is already
    populated (CRR Art. 153(1); CRR Art. 162(2)(b)).
    """
    # Recover model_id for rows that carry only the counterparty's resolved
    # internal_model_id (synthetic CCR rows). No-op when model_id is already
    # set. Both columns are contract-guaranteed: model_id on hierarchy_exit,
    # cp_internal_model_id via the sealed counterparty lookup join.
    exposures = exposures.with_columns(
        pl.coalesce(pl.col("model_id"), pl.col("cp_internal_model_id")).alias("model_id")
    )

    # The model_permissions frame is sealed at the loader edge
    # (raw_model_permissions), so the optional columns country_codes /
    # excluded_book_codes / ppu_reason are always present — absent input
    # columns arrive as typed nulls (all geographies / no exclusions /
    # no PPU labelling).

    # Join exposures with model_permissions on model_id
    # Each exposure may match multiple permission rows (AIRB + FIRB for same model)
    joined = exposures.join(
        model_permissions.select(
            pl.col("model_id").alias("mp_model_id"),
            pl.col("exposure_class").alias("mp_exposure_class"),
            pl.col("approach").alias("mp_approach"),
            pl.col("country_codes").alias("mp_country_codes"),
            pl.col("excluded_book_codes").alias("mp_excluded_book_codes"),
            pl.col("ppu_reason").alias("mp_ppu_reason"),
        ),
        left_on="model_id",
        right_on="mp_model_id",
        how="left",
    )

    # Track whether the join produced any matching permission row for this
    # exposure (before filters are applied). Used downstream to distinguish
    # "model_id did not match any permission row" from "model_id matched
    # but filters rejected every row", so the diagnostic column can point
    # the user at the right remediation. Note: Polars drops the right
    # join key (mp_model_id) when left_on != right_on, so we probe via
    # mp_exposure_class which stays in the joined frame.
    joined = joined.with_columns(pl.col("mp_exposure_class").is_not_null().alias("_mp_row_joined"))

    # Apply filters: exposure_class match, geography, book code exclusion
    # A permission row is valid when:
    # 1. exposure_class_irb matches (use IRB class so rgla/pse entities typed
    #    as institution / sovereign match model permissions keyed on
    #    INSTITUTION / CGCB per CRR Art. 147(3)-(4))
    # 2. geography passes (country_codes is null OR cp_country_code in list)
    # 3. book code not excluded (excluded_book_codes is null OR book_code NOT in list)
    exposure_class_match = pl.col("exposure_class_irb") == pl.col("mp_exposure_class")

    # Null-safe filter logic (P1.114):
    # Polars `str.contains(<expr>)` propagates null when the needle is null,
    # producing kleene-3-valued OR results (null | null = null) that silently
    # block permission grants. Guard each branch:
    #   - geo: a null cp_country_code cannot prove scope-in, so it fails the
    #     filter when mp_country_codes is non-null (conservative).
    #   - book: a null book_code cannot be in any exclusion list, so the
    #     contains() result is coerced to False before negation.
    geo_passes = pl.col("mp_country_codes").is_null() | (
        pl.col("cp_country_code").is_not_null()
        & pl.col("mp_country_codes").str.contains(pl.col("cp_country_code"))
    )

    book_not_excluded = pl.col("mp_excluded_book_codes").is_null() | ~(
        pl.col("mp_excluded_book_codes").str.contains(pl.col("book_code")).fill_null(False)
    )

    permission_valid = exposure_class_match & geo_passes & book_not_excluded

    # Compute per-row permission flags
    airb_permitted = (permission_valid & (pl.col("mp_approach") == ApproachType.AIRB.value)).alias(
        "_airb_match"
    )
    firb_permitted = (permission_valid & (pl.col("mp_approach") == ApproachType.FIRB.value)).alias(
        "_firb_match"
    )
    slotting_permitted = (
        permission_valid & (pl.col("mp_approach") == ApproachType.SLOTTING.value)
    ).alias("_slotting_match")

    # SA-precedence (P1.145, CRR Art. 150(1) PPU carve-out): when the same
    # (model_id, exposure_class) yields both an IRB permission row and a
    # standardised row, the standardised row wins. AIRB-wins via .max()
    # would silently expand IRB scope beyond the firm's permission.
    sa_block = (permission_valid & (pl.col("mp_approach") == ApproachType.SA.value)).alias(
        "_sa_block_match"
    )

    # CRR Art. 150(1) PPU / Art. 148 roll-out provenance: capture the ppu_reason
    # from the surviving SA-precedence row only. Null on non-SA rows so the
    # max().over() roll-up below picks up the SA row's label (and stays null
    # when no SA-routing permission applied).
    sa_ppu_reason = (
        pl.when(sa_block).then(pl.col("mp_ppu_reason")).otherwise(None).alias("_sa_ppu_reason")
    )

    # Add match flags then aggregate: group by all original columns,
    # take max of the match flags (any valid AIRB/FIRB/slotting permission → True),
    # then AND-NOT the SA block to apply the SA-precedence rule.
    result = joined.with_columns(
        airb_permitted, firb_permitted, slotting_permitted, sa_block, sa_ppu_reason
    )

    # Aggregate back to one row per exposure using .over() to avoid group_by.
    # SA-precedence override is applied AFTER the .max() roll-up so any SA
    # row with permission_valid=True flips all IRB flags to False.
    result = result.with_columns(
        pl.col("_sa_block_match").max().over("exposure_reference").alias("_sa_block"),
        pl.col("_mp_row_joined").max().over("exposure_reference").alias("_mp_joined_any"),
        pl.col("_sa_ppu_reason").max().over("exposure_reference").alias("ppu_reason"),
    ).with_columns(
        (pl.col("_airb_match").max().over("exposure_reference") & ~pl.col("_sa_block")).alias(
            "model_airb_permitted"
        ),
        (pl.col("_firb_match").max().over("exposure_reference") & ~pl.col("_sa_block")).alias(
            "model_firb_permitted"
        ),
        (pl.col("_slotting_match").max().over("exposure_reference") & ~pl.col("_sa_block")).alias(
            "model_slotting_permitted"
        ),
    )

    # Diagnostic column: tag WHY a row did not get an IRB permission match.
    # Three causes with distinct remediations:
    #   null_model_id       → rating.model_id is null (fix ratings table)
    #   unmatched_model_id  → model_id absent from model_permissions (stale ref)
    #   filter_rejected     → matched but filtered by class/geo/book scope
    # Null when the exposure DID get a match (happy path).
    has_any_match = (
        pl.col("model_airb_permitted")
        | pl.col("model_firb_permitted")
        | pl.col("model_slotting_permitted")
    )
    result = result.with_columns(
        pl.when(has_any_match)
        .then(pl.lit(None, dtype=pl.String))
        .when(pl.col("model_id").is_null())
        .then(pl.lit("null_model_id"))
        .when(~pl.col("_mp_joined_any"))
        .then(pl.lit("unmatched_model_id"))
        .otherwise(pl.lit("filter_rejected"))
        .alias("_model_permission_diagnostic")
    )

    # Drop the join columns and keep one row per exposure deterministically
    # (P1.145, Step 3): sort by a total-order key so that whichever row of
    # the duplicate-permission join survives `unique(keep="first")` does
    # not depend on the physical row order of the input parquet. The
    # priority key keeps the most-informative diagnostic on the surviving
    # row (null > filter_rejected > unmatched_model_id > null_model_id).
    diagnostic_priority = (
        pl.when(pl.col("_model_permission_diagnostic").is_null())
        .then(pl.lit(0))
        .when(pl.col("_model_permission_diagnostic") == "filter_rejected")
        .then(pl.lit(1))
        .when(pl.col("_model_permission_diagnostic") == "unmatched_model_id")
        .then(pl.lit(2))
        .otherwise(pl.lit(3))
        .alias("_diagnostic_priority")
    )
    result = (
        result.with_columns(diagnostic_priority)
        .sort(
            [
                "exposure_reference",
                "_diagnostic_priority",
                "mp_approach",
                "mp_country_codes",
                "mp_excluded_book_codes",
            ],
            nulls_last=True,
            maintain_order=True,
        )
        .unique(subset=["exposure_reference"], keep="first", maintain_order=True)
        .select(
            pl.exclude(
                "mp_exposure_class",
                "mp_approach",
                "mp_country_codes",
                "mp_excluded_book_codes",
                "mp_ppu_reason",
                "_sa_ppu_reason",
                "_airb_match",
                "_firb_match",
                "_slotting_match",
                "_sa_block_match",
                "_sa_block",
                "_mp_row_joined",
                "_mp_joined_any",
                "_diagnostic_priority",
            )
        )
    )

    return result

CRR Art. 144 — Competent authorities' assessment of an application

Supervisory process — out of scope for a calculator. IRB permissions enter the engine via the model_permissions input table; the calculator trusts whatever rows the firm supplies.

CRR Art. 145 — Prior experience with IRB approaches

Supervisory process — out of scope. Prior-experience review is tracked in supervisory records, not in calculator state.

CRR Art. 146 — Measures to be taken where requirements cease to be met

Supervisory revocation — out of scope. Revocation is realised operationally by removing rows from the model_permissions input.

CRR Art. 147 — Methodology to assign exposures to exposure classes

_align_irb_exposure_class — src/rwa_calc/engine/stages/classify/approach.py:324
@cites("CRR Art. 147")
def _align_irb_exposure_class(exposures: pl.LazyFrame) -> pl.LazyFrame:
    """Align exposure_class with exposure_class_irb for rgla/pse rows.

    For IRB-routed rgla_* / pse_* rows, the IRB calculator (which reads
    exposure_class for correlation/LGD selection) needs CGCB / INSTITUTION
    rather than the SA labels RGLA / PSE. Scoped to these entity types
    because later phases (retail reclassification, SME/QRRE) mutate
    exposure_class in place without updating exposure_class_irb — a
    blanket rewrite would revert those legitimate adjustments.
    """
    needs_alignment = pl.col("cp_entity_type").is_in(list(RGLA_PSE_ENTITY_TYPES))
    return exposures.with_columns(
        pl.when(
            pl.col("approach").is_in([ApproachType.FIRB.value, ApproachType.AIRB.value])
            & needs_alignment
        )
        .then(pl.col("exposure_class_irb"))
        .otherwise(pl.col("exposure_class"))
        .alias("exposure_class")
    )
classify — src/rwa_calc/engine/stages/classify/classifier.py:105
    @cites("CRR Art. 112")
    @cites("CRR Art. 147")
    def classify(
        self,
        data: ResolvedHierarchyBundle,
        config: CalculationConfig,
        *,
        pack: ResolvedRulepack | None = None,
    ) -> ClassifiedExposuresBundle:
        """
        Classify exposures and split by approach.

        Args:
            data: Hierarchy-resolved data from HierarchyResolver
            config: Calculation configuration

        Returns:
            ClassifiedExposuresBundle with exposures split by approach
        """
        resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack

        # Reads top-to-bottom as a recipe; each helper owns one regulatory
        # concept. See the sibling sub-modules for per-step regulatory
        # references.
        exposures = add_counterparty_attributes(
            data.exposures,
            data.counterparty_lookup.counterparties,
        )
        exposures = join_specialised_lending(exposures, data.specialised_lending)

        # Single schema snapshot — used by the remaining schema-conditional
        # helpers (non-contract scratch columns and the EU-sovereign currency
        # probe) without re-scanning the LazyFrame. Contract columns
        # (hierarchy_exit / cp_lookup_* / raw_model_permissions) need no
        # presence gate — sealed inputs always carry them.
        schema_names = set(exposures.collect_schema().names())

        classification_errors = collect_input_warnings(data, config, pack=resolved_pack)

        classified = derive_independent_flags(exposures, config, schema_names, pack=resolved_pack)
        classified = classify_exposure_subtypes(classified, config, pack=resolved_pack)
        classified = reclassify_corporate_to_retail(
            classified, config, schema_names, pack=resolved_pack
        )
        classified = flag_property_reclassification_candidates(
            classified, config, schema_names, pack=resolved_pack
        )
        classified = sync_irb_exposure_class(classified)

        has_model_permissions = data.model_permissions is not None
        if data.model_permissions is not None:
            classified = resolve_model_permissions(classified, data.model_permissions)

        classified = assign_approach(
            classified,
            config,
            schema_names,
            has_model_permissions=has_model_permissions,
            pack=resolved_pack,
        )
        classified = derive_exposure_subclass(classified, config, pack=resolved_pack)

        # Stage-exit edge (producer-side): the diagnostic emits below run
        # against in-memory data instead of re-executing the upstream lazy
        # plan, and CRMProcessor receives an eager-backed frame. Laziness is
        # strictly intra-stage (migration Phase 1).
        classified = materialise_edge(classified, config, "classifier_exit")

        classification_errors.extend(collect_beel_on_non_defaulted_warnings(classified))
        if has_model_permissions:
            classification_errors.extend(emit_model_permission_diagnostics(classified))

        # Producer seal (Phase 3): validates the contract and strips
        # intra-stage scratch (including _model_permission_diagnostic) —
        # pure plan ops over the eager-backed frame. CCR runs carry the
        # SA-CCR provenance columns through, so the contract is selected
        # by the input frame's brand.
        exit_edge = (
            CLASSIFIER_EXIT_CCR_EDGE
            if sealed_edge_of(data.exposures) == "ccr_exit"
            else CLASSIFIER_EXIT_EDGE
        )
        classified = seal(classified, exit_edge)

        return self._build_bundle(classified, data, classification_errors)

CRR Art. 147A — IRB approach restrictions (Basel 3.1)

Implemented in engine/stages/classify/approach.py::_apply_b31_approach_restrictions. CRR-side decoration is deferred — Art. 147A is a Basel 3.1 amendment with no original CRR equivalent, and watchfire's bundled CRR index does not cover the A suffix (see citation-tracking.md on alphanumeric article handling). The PS1/26 paragraph mapping is pending a future review.

CRR Art. 148 — Conditions for implementing the IRB Approach across different classes of exposure and business units

resolve_model_permissions — src/rwa_calc/engine/stages/classify/permissions.py:55
@cites("CRR Art. 143")
@cites("CRR Art. 148")
@cites("CRR Art. 150")
def resolve_model_permissions(
    exposures: pl.LazyFrame,
    model_permissions: pl.LazyFrame,
) -> pl.LazyFrame:
    """
    Join exposures with model_permissions to produce per-row permission flags.

    model_id originates on internal ratings and is propagated to exposures by
    the rating inheritance pipeline. This method resolves which IRB approach each
    exposure is permitted to use based on:
    - model_id match (rating's model_id must exist in model_permissions)
    - exposure_class match
    - Geography filter: country_codes is null OR cp_country_code is in the list
    - Book code exclusion: excluded_book_codes is null OR book_code NOT in the list

    Priority: AIRB > FIRB. If a model has both, AIRB wins for exposures that
    also have modelled LGD; otherwise FIRB is used if the exposure has internal_pd.

    Sets: model_airb_permitted (bool), model_firb_permitted (bool),
          model_slotting_permitted (bool)

    Exposures without a model_id get all flags as False (→ SA fallback).

    Synthetic CCR rows reach this stage with ``model_id = null`` because the
    rating-inheritance attach that renames ``internal_model_id`` -> ``model_id``
    only runs over hierarchy-resolved lending rows. When the counterparty
    lookup carried an ``internal_model_id`` it was surfaced as
    ``cp_internal_model_id`` by ``_add_counterparty_attributes``; coalescing it
    into ``model_id`` here lets an IRB-permissioned counterparty's CCR
    derivative exposure resolve a model permission instead of falling back to
    SA. The coalesce is a no-op for lending rows whose ``model_id`` is already
    populated (CRR Art. 153(1); CRR Art. 162(2)(b)).
    """
    # Recover model_id for rows that carry only the counterparty's resolved
    # internal_model_id (synthetic CCR rows). No-op when model_id is already
    # set. Both columns are contract-guaranteed: model_id on hierarchy_exit,
    # cp_internal_model_id via the sealed counterparty lookup join.
    exposures = exposures.with_columns(
        pl.coalesce(pl.col("model_id"), pl.col("cp_internal_model_id")).alias("model_id")
    )

    # The model_permissions frame is sealed at the loader edge
    # (raw_model_permissions), so the optional columns country_codes /
    # excluded_book_codes / ppu_reason are always present — absent input
    # columns arrive as typed nulls (all geographies / no exclusions /
    # no PPU labelling).

    # Join exposures with model_permissions on model_id
    # Each exposure may match multiple permission rows (AIRB + FIRB for same model)
    joined = exposures.join(
        model_permissions.select(
            pl.col("model_id").alias("mp_model_id"),
            pl.col("exposure_class").alias("mp_exposure_class"),
            pl.col("approach").alias("mp_approach"),
            pl.col("country_codes").alias("mp_country_codes"),
            pl.col("excluded_book_codes").alias("mp_excluded_book_codes"),
            pl.col("ppu_reason").alias("mp_ppu_reason"),
        ),
        left_on="model_id",
        right_on="mp_model_id",
        how="left",
    )

    # Track whether the join produced any matching permission row for this
    # exposure (before filters are applied). Used downstream to distinguish
    # "model_id did not match any permission row" from "model_id matched
    # but filters rejected every row", so the diagnostic column can point
    # the user at the right remediation. Note: Polars drops the right
    # join key (mp_model_id) when left_on != right_on, so we probe via
    # mp_exposure_class which stays in the joined frame.
    joined = joined.with_columns(pl.col("mp_exposure_class").is_not_null().alias("_mp_row_joined"))

    # Apply filters: exposure_class match, geography, book code exclusion
    # A permission row is valid when:
    # 1. exposure_class_irb matches (use IRB class so rgla/pse entities typed
    #    as institution / sovereign match model permissions keyed on
    #    INSTITUTION / CGCB per CRR Art. 147(3)-(4))
    # 2. geography passes (country_codes is null OR cp_country_code in list)
    # 3. book code not excluded (excluded_book_codes is null OR book_code NOT in list)
    exposure_class_match = pl.col("exposure_class_irb") == pl.col("mp_exposure_class")

    # Null-safe filter logic (P1.114):
    # Polars `str.contains(<expr>)` propagates null when the needle is null,
    # producing kleene-3-valued OR results (null | null = null) that silently
    # block permission grants. Guard each branch:
    #   - geo: a null cp_country_code cannot prove scope-in, so it fails the
    #     filter when mp_country_codes is non-null (conservative).
    #   - book: a null book_code cannot be in any exclusion list, so the
    #     contains() result is coerced to False before negation.
    geo_passes = pl.col("mp_country_codes").is_null() | (
        pl.col("cp_country_code").is_not_null()
        & pl.col("mp_country_codes").str.contains(pl.col("cp_country_code"))
    )

    book_not_excluded = pl.col("mp_excluded_book_codes").is_null() | ~(
        pl.col("mp_excluded_book_codes").str.contains(pl.col("book_code")).fill_null(False)
    )

    permission_valid = exposure_class_match & geo_passes & book_not_excluded

    # Compute per-row permission flags
    airb_permitted = (permission_valid & (pl.col("mp_approach") == ApproachType.AIRB.value)).alias(
        "_airb_match"
    )
    firb_permitted = (permission_valid & (pl.col("mp_approach") == ApproachType.FIRB.value)).alias(
        "_firb_match"
    )
    slotting_permitted = (
        permission_valid & (pl.col("mp_approach") == ApproachType.SLOTTING.value)
    ).alias("_slotting_match")

    # SA-precedence (P1.145, CRR Art. 150(1) PPU carve-out): when the same
    # (model_id, exposure_class) yields both an IRB permission row and a
    # standardised row, the standardised row wins. AIRB-wins via .max()
    # would silently expand IRB scope beyond the firm's permission.
    sa_block = (permission_valid & (pl.col("mp_approach") == ApproachType.SA.value)).alias(
        "_sa_block_match"
    )

    # CRR Art. 150(1) PPU / Art. 148 roll-out provenance: capture the ppu_reason
    # from the surviving SA-precedence row only. Null on non-SA rows so the
    # max().over() roll-up below picks up the SA row's label (and stays null
    # when no SA-routing permission applied).
    sa_ppu_reason = (
        pl.when(sa_block).then(pl.col("mp_ppu_reason")).otherwise(None).alias("_sa_ppu_reason")
    )

    # Add match flags then aggregate: group by all original columns,
    # take max of the match flags (any valid AIRB/FIRB/slotting permission → True),
    # then AND-NOT the SA block to apply the SA-precedence rule.
    result = joined.with_columns(
        airb_permitted, firb_permitted, slotting_permitted, sa_block, sa_ppu_reason
    )

    # Aggregate back to one row per exposure using .over() to avoid group_by.
    # SA-precedence override is applied AFTER the .max() roll-up so any SA
    # row with permission_valid=True flips all IRB flags to False.
    result = result.with_columns(
        pl.col("_sa_block_match").max().over("exposure_reference").alias("_sa_block"),
        pl.col("_mp_row_joined").max().over("exposure_reference").alias("_mp_joined_any"),
        pl.col("_sa_ppu_reason").max().over("exposure_reference").alias("ppu_reason"),
    ).with_columns(
        (pl.col("_airb_match").max().over("exposure_reference") & ~pl.col("_sa_block")).alias(
            "model_airb_permitted"
        ),
        (pl.col("_firb_match").max().over("exposure_reference") & ~pl.col("_sa_block")).alias(
            "model_firb_permitted"
        ),
        (pl.col("_slotting_match").max().over("exposure_reference") & ~pl.col("_sa_block")).alias(
            "model_slotting_permitted"
        ),
    )

    # Diagnostic column: tag WHY a row did not get an IRB permission match.
    # Three causes with distinct remediations:
    #   null_model_id       → rating.model_id is null (fix ratings table)
    #   unmatched_model_id  → model_id absent from model_permissions (stale ref)
    #   filter_rejected     → matched but filtered by class/geo/book scope
    # Null when the exposure DID get a match (happy path).
    has_any_match = (
        pl.col("model_airb_permitted")
        | pl.col("model_firb_permitted")
        | pl.col("model_slotting_permitted")
    )
    result = result.with_columns(
        pl.when(has_any_match)
        .then(pl.lit(None, dtype=pl.String))
        .when(pl.col("model_id").is_null())
        .then(pl.lit("null_model_id"))
        .when(~pl.col("_mp_joined_any"))
        .then(pl.lit("unmatched_model_id"))
        .otherwise(pl.lit("filter_rejected"))
        .alias("_model_permission_diagnostic")
    )

    # Drop the join columns and keep one row per exposure deterministically
    # (P1.145, Step 3): sort by a total-order key so that whichever row of
    # the duplicate-permission join survives `unique(keep="first")` does
    # not depend on the physical row order of the input parquet. The
    # priority key keeps the most-informative diagnostic on the surviving
    # row (null > filter_rejected > unmatched_model_id > null_model_id).
    diagnostic_priority = (
        pl.when(pl.col("_model_permission_diagnostic").is_null())
        .then(pl.lit(0))
        .when(pl.col("_model_permission_diagnostic") == "filter_rejected")
        .then(pl.lit(1))
        .when(pl.col("_model_permission_diagnostic") == "unmatched_model_id")
        .then(pl.lit(2))
        .otherwise(pl.lit(3))
        .alias("_diagnostic_priority")
    )
    result = (
        result.with_columns(diagnostic_priority)
        .sort(
            [
                "exposure_reference",
                "_diagnostic_priority",
                "mp_approach",
                "mp_country_codes",
                "mp_excluded_book_codes",
            ],
            nulls_last=True,
            maintain_order=True,
        )
        .unique(subset=["exposure_reference"], keep="first", maintain_order=True)
        .select(
            pl.exclude(
                "mp_exposure_class",
                "mp_approach",
                "mp_country_codes",
                "mp_excluded_book_codes",
                "mp_ppu_reason",
                "_sa_ppu_reason",
                "_airb_match",
                "_firb_match",
                "_slotting_match",
                "_sa_block_match",
                "_sa_block",
                "_mp_row_joined",
                "_mp_joined_any",
                "_diagnostic_priority",
            )
        )
    )

    return result

CRR Art. 149 — Conditions for reverting to the use of less sophisticated approaches

Supervisory process — out of scope. Reversion is realised operationally via removal of permission rows; the calculator automatically falls back to SA when no IRB permission matches.

CRR Art. 150 — Conditions for permanent partial use

resolve_model_permissions — src/rwa_calc/engine/stages/classify/permissions.py:56
@cites("CRR Art. 143")
@cites("CRR Art. 148")
@cites("CRR Art. 150")
def resolve_model_permissions(
    exposures: pl.LazyFrame,
    model_permissions: pl.LazyFrame,
) -> pl.LazyFrame:
    """
    Join exposures with model_permissions to produce per-row permission flags.

    model_id originates on internal ratings and is propagated to exposures by
    the rating inheritance pipeline. This method resolves which IRB approach each
    exposure is permitted to use based on:
    - model_id match (rating's model_id must exist in model_permissions)
    - exposure_class match
    - Geography filter: country_codes is null OR cp_country_code is in the list
    - Book code exclusion: excluded_book_codes is null OR book_code NOT in the list

    Priority: AIRB > FIRB. If a model has both, AIRB wins for exposures that
    also have modelled LGD; otherwise FIRB is used if the exposure has internal_pd.

    Sets: model_airb_permitted (bool), model_firb_permitted (bool),
          model_slotting_permitted (bool)

    Exposures without a model_id get all flags as False (→ SA fallback).

    Synthetic CCR rows reach this stage with ``model_id = null`` because the
    rating-inheritance attach that renames ``internal_model_id`` -> ``model_id``
    only runs over hierarchy-resolved lending rows. When the counterparty
    lookup carried an ``internal_model_id`` it was surfaced as
    ``cp_internal_model_id`` by ``_add_counterparty_attributes``; coalescing it
    into ``model_id`` here lets an IRB-permissioned counterparty's CCR
    derivative exposure resolve a model permission instead of falling back to
    SA. The coalesce is a no-op for lending rows whose ``model_id`` is already
    populated (CRR Art. 153(1); CRR Art. 162(2)(b)).
    """
    # Recover model_id for rows that carry only the counterparty's resolved
    # internal_model_id (synthetic CCR rows). No-op when model_id is already
    # set. Both columns are contract-guaranteed: model_id on hierarchy_exit,
    # cp_internal_model_id via the sealed counterparty lookup join.
    exposures = exposures.with_columns(
        pl.coalesce(pl.col("model_id"), pl.col("cp_internal_model_id")).alias("model_id")
    )

    # The model_permissions frame is sealed at the loader edge
    # (raw_model_permissions), so the optional columns country_codes /
    # excluded_book_codes / ppu_reason are always present — absent input
    # columns arrive as typed nulls (all geographies / no exclusions /
    # no PPU labelling).

    # Join exposures with model_permissions on model_id
    # Each exposure may match multiple permission rows (AIRB + FIRB for same model)
    joined = exposures.join(
        model_permissions.select(
            pl.col("model_id").alias("mp_model_id"),
            pl.col("exposure_class").alias("mp_exposure_class"),
            pl.col("approach").alias("mp_approach"),
            pl.col("country_codes").alias("mp_country_codes"),
            pl.col("excluded_book_codes").alias("mp_excluded_book_codes"),
            pl.col("ppu_reason").alias("mp_ppu_reason"),
        ),
        left_on="model_id",
        right_on="mp_model_id",
        how="left",
    )

    # Track whether the join produced any matching permission row for this
    # exposure (before filters are applied). Used downstream to distinguish
    # "model_id did not match any permission row" from "model_id matched
    # but filters rejected every row", so the diagnostic column can point
    # the user at the right remediation. Note: Polars drops the right
    # join key (mp_model_id) when left_on != right_on, so we probe via
    # mp_exposure_class which stays in the joined frame.
    joined = joined.with_columns(pl.col("mp_exposure_class").is_not_null().alias("_mp_row_joined"))

    # Apply filters: exposure_class match, geography, book code exclusion
    # A permission row is valid when:
    # 1. exposure_class_irb matches (use IRB class so rgla/pse entities typed
    #    as institution / sovereign match model permissions keyed on
    #    INSTITUTION / CGCB per CRR Art. 147(3)-(4))
    # 2. geography passes (country_codes is null OR cp_country_code in list)
    # 3. book code not excluded (excluded_book_codes is null OR book_code NOT in list)
    exposure_class_match = pl.col("exposure_class_irb") == pl.col("mp_exposure_class")

    # Null-safe filter logic (P1.114):
    # Polars `str.contains(<expr>)` propagates null when the needle is null,
    # producing kleene-3-valued OR results (null | null = null) that silently
    # block permission grants. Guard each branch:
    #   - geo: a null cp_country_code cannot prove scope-in, so it fails the
    #     filter when mp_country_codes is non-null (conservative).
    #   - book: a null book_code cannot be in any exclusion list, so the
    #     contains() result is coerced to False before negation.
    geo_passes = pl.col("mp_country_codes").is_null() | (
        pl.col("cp_country_code").is_not_null()
        & pl.col("mp_country_codes").str.contains(pl.col("cp_country_code"))
    )

    book_not_excluded = pl.col("mp_excluded_book_codes").is_null() | ~(
        pl.col("mp_excluded_book_codes").str.contains(pl.col("book_code")).fill_null(False)
    )

    permission_valid = exposure_class_match & geo_passes & book_not_excluded

    # Compute per-row permission flags
    airb_permitted = (permission_valid & (pl.col("mp_approach") == ApproachType.AIRB.value)).alias(
        "_airb_match"
    )
    firb_permitted = (permission_valid & (pl.col("mp_approach") == ApproachType.FIRB.value)).alias(
        "_firb_match"
    )
    slotting_permitted = (
        permission_valid & (pl.col("mp_approach") == ApproachType.SLOTTING.value)
    ).alias("_slotting_match")

    # SA-precedence (P1.145, CRR Art. 150(1) PPU carve-out): when the same
    # (model_id, exposure_class) yields both an IRB permission row and a
    # standardised row, the standardised row wins. AIRB-wins via .max()
    # would silently expand IRB scope beyond the firm's permission.
    sa_block = (permission_valid & (pl.col("mp_approach") == ApproachType.SA.value)).alias(
        "_sa_block_match"
    )

    # CRR Art. 150(1) PPU / Art. 148 roll-out provenance: capture the ppu_reason
    # from the surviving SA-precedence row only. Null on non-SA rows so the
    # max().over() roll-up below picks up the SA row's label (and stays null
    # when no SA-routing permission applied).
    sa_ppu_reason = (
        pl.when(sa_block).then(pl.col("mp_ppu_reason")).otherwise(None).alias("_sa_ppu_reason")
    )

    # Add match flags then aggregate: group by all original columns,
    # take max of the match flags (any valid AIRB/FIRB/slotting permission → True),
    # then AND-NOT the SA block to apply the SA-precedence rule.
    result = joined.with_columns(
        airb_permitted, firb_permitted, slotting_permitted, sa_block, sa_ppu_reason
    )

    # Aggregate back to one row per exposure using .over() to avoid group_by.
    # SA-precedence override is applied AFTER the .max() roll-up so any SA
    # row with permission_valid=True flips all IRB flags to False.
    result = result.with_columns(
        pl.col("_sa_block_match").max().over("exposure_reference").alias("_sa_block"),
        pl.col("_mp_row_joined").max().over("exposure_reference").alias("_mp_joined_any"),
        pl.col("_sa_ppu_reason").max().over("exposure_reference").alias("ppu_reason"),
    ).with_columns(
        (pl.col("_airb_match").max().over("exposure_reference") & ~pl.col("_sa_block")).alias(
            "model_airb_permitted"
        ),
        (pl.col("_firb_match").max().over("exposure_reference") & ~pl.col("_sa_block")).alias(
            "model_firb_permitted"
        ),
        (pl.col("_slotting_match").max().over("exposure_reference") & ~pl.col("_sa_block")).alias(
            "model_slotting_permitted"
        ),
    )

    # Diagnostic column: tag WHY a row did not get an IRB permission match.
    # Three causes with distinct remediations:
    #   null_model_id       → rating.model_id is null (fix ratings table)
    #   unmatched_model_id  → model_id absent from model_permissions (stale ref)
    #   filter_rejected     → matched but filtered by class/geo/book scope
    # Null when the exposure DID get a match (happy path).
    has_any_match = (
        pl.col("model_airb_permitted")
        | pl.col("model_firb_permitted")
        | pl.col("model_slotting_permitted")
    )
    result = result.with_columns(
        pl.when(has_any_match)
        .then(pl.lit(None, dtype=pl.String))
        .when(pl.col("model_id").is_null())
        .then(pl.lit("null_model_id"))
        .when(~pl.col("_mp_joined_any"))
        .then(pl.lit("unmatched_model_id"))
        .otherwise(pl.lit("filter_rejected"))
        .alias("_model_permission_diagnostic")
    )

    # Drop the join columns and keep one row per exposure deterministically
    # (P1.145, Step 3): sort by a total-order key so that whichever row of
    # the duplicate-permission join survives `unique(keep="first")` does
    # not depend on the physical row order of the input parquet. The
    # priority key keeps the most-informative diagnostic on the surviving
    # row (null > filter_rejected > unmatched_model_id > null_model_id).
    diagnostic_priority = (
        pl.when(pl.col("_model_permission_diagnostic").is_null())
        .then(pl.lit(0))
        .when(pl.col("_model_permission_diagnostic") == "filter_rejected")
        .then(pl.lit(1))
        .when(pl.col("_model_permission_diagnostic") == "unmatched_model_id")
        .then(pl.lit(2))
        .otherwise(pl.lit(3))
        .alias("_diagnostic_priority")
    )
    result = (
        result.with_columns(diagnostic_priority)
        .sort(
            [
                "exposure_reference",
                "_diagnostic_priority",
                "mp_approach",
                "mp_country_codes",
                "mp_excluded_book_codes",
            ],
            nulls_last=True,
            maintain_order=True,
        )
        .unique(subset=["exposure_reference"], keep="first", maintain_order=True)
        .select(
            pl.exclude(
                "mp_exposure_class",
                "mp_approach",
                "mp_country_codes",
                "mp_excluded_book_codes",
                "mp_ppu_reason",
                "_sa_ppu_reason",
                "_airb_match",
                "_firb_match",
                "_slotting_match",
                "_sa_block_match",
                "_sa_block",
                "_mp_row_joined",
                "_mp_joined_any",
                "_diagnostic_priority",
            )
        )
    )

    return result

CRR Art. 151 — Treatment by exposure class

apply_irb_formulas — src/rwa_calc/engine/irb/formulas.py:413
@cites("CRR Art. 151")
@cites("CRR Art. 153")
@cites("CRR Art. 154")
def apply_irb_formulas(
    exposures: pl.LazyFrame,
    config: CalculationConfig,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """
    Apply IRB formulas to exposures using pure Polars expressions.

    Uses polars-normal-stats for statistical functions (normal_cdf, normal_ppf),
    enabling full lazy evaluation, query optimization, and streaming.

    Expects columns: pd, lgd, ead_final, exposure_class
    Optional: maturity, turnover_m (for SME correlation adjustment)

    Adds columns: pd_floored, lgd_floored, correlation, k, maturity_adjustment,
                  scaling_factor, risk_weight, rwa, expected_loss

    Args:
        exposures: LazyFrame with IRB exposures
        config: Calculation configuration

    Returns:
        LazyFrame with IRB calculations added
    """
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
    scaling_factor = scalar_value(resolved_pack.scalar_param("irb_scaling_factor"))

    # Ensure calculator-internal derived columns exist (maturity / turnover_m
    # are produced by ``prepare_columns`` on the namespace path and are not
    # crm_exit contract columns).
    schema = exposures.collect_schema()
    schema_names = schema.names()
    if "maturity" not in schema_names:
        exposures = exposures.with_columns(pl.lit(2.5).alias("maturity"))
    if "turnover_m" not in schema_names:
        exposures = exposures.with_columns(pl.lit(None).cast(pl.Float64).alias("turnover_m"))

    # Step 1: Apply per-exposure-class PD floor (CRR: uniform, Basel 3.1: differentiated)
    pd_floor_expr = _pd_floor_expression(config, pack=resolved_pack)
    exposures = exposures.with_columns(
        pl.max_horizontal(pl.col("pd"), pd_floor_expr).alias("pd_floored")
    )

    # Step 2: Apply LGD floor (Basel 3.1 A-IRB only, CRR has no LGD floors)
    # LGD floors only apply to A-IRB own-estimate LGDs (CRE30.41).
    # F-IRB supervisory LGDs are regulatory values and don't need flooring.
    if resolved_pack.feature("airb_lgd_floor"):
        if "collateral_type" in schema_names:
            lgd_floor_expr = _lgd_floor_expression_with_collateral(
                config,
                has_seniority=True,
                has_exposure_class=True,
                pack=resolved_pack,
            )
        else:
            lgd_floor_expr = _lgd_floor_expression(
                config,
                has_seniority=True,
                has_exposure_class=True,
                pack=resolved_pack,
            )
        # Art. 164(4)(c) blended floor for retail with mixed collateral
        blended_expr = _lgd_floor_blended_expression(config, pack=resolved_pack)
        lgd_floor_expr = (
            pl.when(blended_expr.is_not_null()).then(blended_expr).otherwise(lgd_floor_expr)
        )
        is_airb = pl.col("is_airb").fill_null(False) if "is_airb" in schema_names else pl.lit(False)
        floored_lgd = pl.max_horizontal(pl.col("lgd"), lgd_floor_expr)
        exposures = exposures.with_columns(
            pl.when(is_airb).then(floored_lgd).otherwise(pl.col("lgd")).alias("lgd_floored")
        )
    else:
        exposures = exposures.with_columns(pl.col("lgd").alias("lgd_floored"))

    # Step 3: Calculate correlation using pure Polars expressions
    # B31 uses GBP-native thresholds (Art. 153(4)); CRR converts GBP→EUR via rate
    eur_gbp_rate = float(config.eur_gbp_rate)
    sme_turnover_m = (
        float(regulatory_threshold(resolved_pack, "sme_turnover_threshold", config.eur_gbp_rate))
        / 1_000_000
    )
    exposures = exposures.with_columns(
        _polars_correlation_expr(
            eur_gbp_rate=eur_gbp_rate,
            is_b31=resolved_pack.feature("irb_correlation_sme_gbp_native"),
            sme_turnover_threshold_m=sme_turnover_m,
        ).alias("correlation")
    )

    # Step 4: Calculate K using pure Polars with polars-normal-stats
    exposures = exposures.with_columns(_polars_capital_k_expr().alias("k"))

    # Step 5: Calculate maturity adjustment (only for non-retail)
    is_retail = (
        pl.col("exposure_class")
        .cast(pl.String)
        .fill_null("CORPORATE")
        .str.to_uppercase()
        .str.contains("RETAIL")
    )

    exposures = exposures.with_columns(
        pl.when(is_retail)
        .then(pl.lit(1.0))
        .otherwise(_polars_maturity_adjustment_expr())
        .alias("maturity_adjustment")
    )

    # Step 6-9: Final calculations (pure Polars expressions)
    exposures = exposures.with_columns(
        [
            pl.lit(scaling_factor).alias("scaling_factor"),
            (
                pl.col("k")
                * 12.5
                * scaling_factor
                * pl.col("ead_final")
                * pl.col("maturity_adjustment")
            ).alias("rwa"),
            (pl.col("k") * 12.5 * scaling_factor * pl.col("maturity_adjustment")).alias(
                "risk_weight"
            ),
            (pl.col("pd_floored") * pl.col("lgd_floored") * pl.col("ead_final")).alias(
                "expected_loss"
            ),
        ]
    )

    # Step 10: Override for defaulted exposures (CRR Art. 153(1)(ii) / 154(1)(i))
    # Delegates to the single source of truth in adjustments.py to avoid divergence.
    from rwa_calc.engine.irb.adjustments import apply_defaulted_treatment

    exposures = apply_defaulted_treatment(exposures)

    return exposures

CRR Art. 152 — Treatment of exposures in the form of CIU units (IRB)

Not implemented — CIU look-through under IRB is out of scope for this calculator. Equity-class CIU treatment under SA is in scope via engine/equity/calculator.py::_append_ciu_branches (see PS1/26, paragraph 132).

CRR Art. 109 — Treatment of securitisation positions

allocate — src/rwa_calc/engine/securitisation/allocator.py:116
    @cites("CRR Art. 109")
    @cites(CRR_ART_244)
    @cites("PS1/26, paragraph 147A")
    def allocate(
        self,
        data: RawDataBundle,
        config: CalculationConfig,  # noqa: ARG002 -- config reserved for future use
    ) -> tuple[RawDataBundle, pl.LazyFrame | None, list[CalculationError]]:
        """Resolve allocations into a per-exposure lookup.

        Args:
            data: Raw data bundle from loader.
            config: Calculation configuration (currently unused; reserved
                so the SRT validation gate can later read framework flags).

        Returns:
            Tuple of (original raw bundle, resolved lookup or None, list
            of validation errors). The lookup is None when no allocations
            were supplied; an empty input frame returns an empty lookup.
        """
        if data.securitisation_allocations is None:
            return data, None, []

        # Materialise once -- the allocator runs row-level validation that
        # is far easier to reason about on a concrete frame than on a
        # lazy plan, and the input table is by definition small (one row
        # per exposure-pool pair).
        raw = data.securitisation_allocations.collect()

        if raw.height == 0:
            return data, empty_resolved_lookup(), []

        errors: list[CalculationError] = []

        # ------------------------------------------------------------------
        # Step 1: SEC002 -- drop rows with invalid allocation_pct.
        # ------------------------------------------------------------------
        invalid_pct = raw.filter(
            (pl.col("allocation_pct").is_null())
            | (pl.col("allocation_pct") <= 0.0)
            | (pl.col("allocation_pct") > 1.0)
        )
        if invalid_pct.height > 0:
            errors.append(
                securitisation_warning(
                    code=ERROR_SEC_INVALID_PCT,
                    message=(
                        f"{invalid_pct.height} securitisation allocation row(s) had "
                        "allocation_pct outside (0, 1] or null; rows dropped."
                    ),
                    severity=ErrorSeverity.ERROR,
                    regulatory_reference=CRR_ART_244,
                )
            )
        raw = raw.filter(
            (pl.col("allocation_pct").is_not_null())
            & (pl.col("allocation_pct") > 0.0)
            & (pl.col("allocation_pct") <= 1.0)
        )

        if raw.height == 0:
            return data, empty_resolved_lookup(), errors

        # ------------------------------------------------------------------
        # Step 2: SEC003 -- orphan exposure_reference (unknown to any of
        # loans / contingents / facilities). Each row is checked against
        # the source table matching its exposure_type to keep the lookup
        # surface narrow.
        # ------------------------------------------------------------------
        known_refs = _collect_known_references(data)
        raw = raw.with_columns(
            pl.struct(["exposure_reference", "exposure_type"])
            .map_elements(
                lambda row: (row["exposure_reference"], row["exposure_type"]) in known_refs,
                return_dtype=pl.Boolean,
            )
            .alias("_is_known"),
        )
        unknown = raw.filter(~pl.col("_is_known"))
        if unknown.height > 0:
            errors.append(
                securitisation_warning(
                    code=ERROR_SEC_UNKNOWN_REFERENCE,
                    message=(
                        f"{unknown.height} securitisation allocation row(s) referenced "
                        "an exposure that does not exist in loans / contingents / "
                        "facilities; rows dropped."
                    ),
                    severity=ErrorSeverity.WARNING,
                    regulatory_reference=CRR_ART_244,
                )
            )
        raw = raw.filter(pl.col("_is_known")).drop("_is_known")

        if raw.height == 0:
            return data, empty_resolved_lookup(), errors

        # ------------------------------------------------------------------
        # Step 3: SEC004 -- duplicate (exposure_reference, pool_reference).
        # Keep first, drop subsequent.
        # ------------------------------------------------------------------
        before_dedup = raw.height
        raw = raw.unique(
            subset=["exposure_reference", "exposure_type", "pool_reference"],
            keep="first",
        )
        dropped = before_dedup - raw.height
        if dropped > 0:
            errors.append(
                securitisation_warning(
                    code=ERROR_SEC_DUPLICATE,
                    message=(
                        f"{dropped} duplicate (exposure_reference, pool_reference) "
                        "securitisation allocation row(s) dropped; first row kept."
                    ),
                    severity=ErrorSeverity.WARNING,
                    regulatory_reference=CRR_ART_244,
                )
            )

        # ------------------------------------------------------------------
        # Step 4: per-exposure aggregation. Group into struct list and
        # compute total_allocated_pct.
        # ------------------------------------------------------------------
        aggregated = (
            raw.lazy()
            .group_by(["exposure_reference", "exposure_type"])
            .agg(
                [
                    pl.struct(
                        [
                            pl.col("pool_reference"),
                            pl.col("allocation_pct"),
                        ]
                    ).alias("securitisation_pool_allocations"),
                    pl.col("allocation_pct").sum().alias("total_allocated_pct"),
                ]
            )
        ).collect()

        # ------------------------------------------------------------------
        # Step 5: SEC001 -- per-exposure sum > 1. Drop the allocations
        # entirely for those rows; the exposure is treated as fully
        # on-balance-sheet (residual_pct = 1.0) with audit_status =
        # "over_allocated" so the audit row still surfaces the issue.
        # ------------------------------------------------------------------
        # Use a small tolerance to absorb floating-point summation noise --
        # ``0.4 + 0.3 + 0.3`` is not exactly 1.0 in IEEE-754.
        _SUM_TOLERANCE = 1e-9
        over = aggregated.filter(pl.col("total_allocated_pct") > 1.0 + _SUM_TOLERANCE)
        if over.height > 0:
            errors.append(
                securitisation_warning(
                    code=ERROR_SEC_OVER_ALLOCATED,
                    message=(
                        f"{over.height} exposure(s) had securitisation allocations "
                        "summing to > 1.0; all pool slices dropped, exposure(s) "
                        "kept fully on-balance-sheet."
                    ),
                    severity=ErrorSeverity.ERROR,
                    regulatory_reference=CRR_ART_244,
                )
            )

        # ------------------------------------------------------------------
        # Step 6: SEC005 -- per-exposure sum == 1 (residual = 0). Inform-
        # ational only -- the exposure flows through the pipeline with
        # zero on-balance-sheet contribution.
        # ------------------------------------------------------------------
        fully = aggregated.filter(
            (pl.col("total_allocated_pct") >= 1.0 - _SUM_TOLERANCE)
            & (pl.col("total_allocated_pct") <= 1.0 + _SUM_TOLERANCE)
        )
        if fully.height > 0:
            errors.append(
                securitisation_warning(
                    code=ERROR_SEC_FULLY_SECURITISED,
                    message=(
                        f"{fully.height} exposure(s) fully securitised "
                        "(residual = 0); zero on-balance-sheet contribution."
                    ),
                    severity=ErrorSeverity.WARNING,
                    regulatory_reference=CRR_ART_244,
                )
            )

        # ------------------------------------------------------------------
        # Step 7: build the resolved lookup. Over-allocated rows keep
        # residual_pct = 1.0 and an empty pool_allocations list so the
        # aggregator does not double-count them.
        # ------------------------------------------------------------------
        is_over = pl.col("total_allocated_pct") > 1.0 + _SUM_TOLERANCE
        is_fully = (pl.col("total_allocated_pct") >= 1.0 - _SUM_TOLERANCE) & (
            pl.col("total_allocated_pct") <= 1.0 + _SUM_TOLERANCE
        )

        empty_struct_list = pl.lit([]).cast(
            pl.List(
                pl.Struct(
                    {
                        "pool_reference": pl.String,
                        "allocation_pct": pl.Float64,
                    }
                )
            )
        )

        resolved = aggregated.with_columns(
            [
                pl.when(is_over)
                .then(pl.lit(1.0))
                .otherwise((pl.lit(1.0) - pl.col("total_allocated_pct")).clip(lower_bound=0.0))
                .alias("securitisation_residual_pct"),
                pl.when(is_over)
                .then(empty_struct_list)
                .otherwise(pl.col("securitisation_pool_allocations"))
                .alias("securitisation_pool_allocations"),
                pl.when(is_over)
                .then(pl.lit("over_allocated"))
                .when(is_fully)
                .then(pl.lit("fully_securitised"))
                .otherwise(pl.lit("ok"))
                .alias("audit_status"),
            ]
        ).select(list(RESOLVED_SECURITISATION_SCHEMA.keys()))

        logger.info(
            "securitisation_allocator resolved %d exposure(s); %d error(s)",
            resolved.height,
            len(errors),
        )

        return data, resolved.lazy(), errors

CRR Art. 153 — Risk-weighted exposure amounts for exposures to corporates, institutions and central governments and central banks

apply_irb_formulas — src/rwa_calc/engine/irb/formulas.py:414
@cites("CRR Art. 151")
@cites("CRR Art. 153")
@cites("CRR Art. 154")
def apply_irb_formulas(
    exposures: pl.LazyFrame,
    config: CalculationConfig,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """
    Apply IRB formulas to exposures using pure Polars expressions.

    Uses polars-normal-stats for statistical functions (normal_cdf, normal_ppf),
    enabling full lazy evaluation, query optimization, and streaming.

    Expects columns: pd, lgd, ead_final, exposure_class
    Optional: maturity, turnover_m (for SME correlation adjustment)

    Adds columns: pd_floored, lgd_floored, correlation, k, maturity_adjustment,
                  scaling_factor, risk_weight, rwa, expected_loss

    Args:
        exposures: LazyFrame with IRB exposures
        config: Calculation configuration

    Returns:
        LazyFrame with IRB calculations added
    """
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
    scaling_factor = scalar_value(resolved_pack.scalar_param("irb_scaling_factor"))

    # Ensure calculator-internal derived columns exist (maturity / turnover_m
    # are produced by ``prepare_columns`` on the namespace path and are not
    # crm_exit contract columns).
    schema = exposures.collect_schema()
    schema_names = schema.names()
    if "maturity" not in schema_names:
        exposures = exposures.with_columns(pl.lit(2.5).alias("maturity"))
    if "turnover_m" not in schema_names:
        exposures = exposures.with_columns(pl.lit(None).cast(pl.Float64).alias("turnover_m"))

    # Step 1: Apply per-exposure-class PD floor (CRR: uniform, Basel 3.1: differentiated)
    pd_floor_expr = _pd_floor_expression(config, pack=resolved_pack)
    exposures = exposures.with_columns(
        pl.max_horizontal(pl.col("pd"), pd_floor_expr).alias("pd_floored")
    )

    # Step 2: Apply LGD floor (Basel 3.1 A-IRB only, CRR has no LGD floors)
    # LGD floors only apply to A-IRB own-estimate LGDs (CRE30.41).
    # F-IRB supervisory LGDs are regulatory values and don't need flooring.
    if resolved_pack.feature("airb_lgd_floor"):
        if "collateral_type" in schema_names:
            lgd_floor_expr = _lgd_floor_expression_with_collateral(
                config,
                has_seniority=True,
                has_exposure_class=True,
                pack=resolved_pack,
            )
        else:
            lgd_floor_expr = _lgd_floor_expression(
                config,
                has_seniority=True,
                has_exposure_class=True,
                pack=resolved_pack,
            )
        # Art. 164(4)(c) blended floor for retail with mixed collateral
        blended_expr = _lgd_floor_blended_expression(config, pack=resolved_pack)
        lgd_floor_expr = (
            pl.when(blended_expr.is_not_null()).then(blended_expr).otherwise(lgd_floor_expr)
        )
        is_airb = pl.col("is_airb").fill_null(False) if "is_airb" in schema_names else pl.lit(False)
        floored_lgd = pl.max_horizontal(pl.col("lgd"), lgd_floor_expr)
        exposures = exposures.with_columns(
            pl.when(is_airb).then(floored_lgd).otherwise(pl.col("lgd")).alias("lgd_floored")
        )
    else:
        exposures = exposures.with_columns(pl.col("lgd").alias("lgd_floored"))

    # Step 3: Calculate correlation using pure Polars expressions
    # B31 uses GBP-native thresholds (Art. 153(4)); CRR converts GBP→EUR via rate
    eur_gbp_rate = float(config.eur_gbp_rate)
    sme_turnover_m = (
        float(regulatory_threshold(resolved_pack, "sme_turnover_threshold", config.eur_gbp_rate))
        / 1_000_000
    )
    exposures = exposures.with_columns(
        _polars_correlation_expr(
            eur_gbp_rate=eur_gbp_rate,
            is_b31=resolved_pack.feature("irb_correlation_sme_gbp_native"),
            sme_turnover_threshold_m=sme_turnover_m,
        ).alias("correlation")
    )

    # Step 4: Calculate K using pure Polars with polars-normal-stats
    exposures = exposures.with_columns(_polars_capital_k_expr().alias("k"))

    # Step 5: Calculate maturity adjustment (only for non-retail)
    is_retail = (
        pl.col("exposure_class")
        .cast(pl.String)
        .fill_null("CORPORATE")
        .str.to_uppercase()
        .str.contains("RETAIL")
    )

    exposures = exposures.with_columns(
        pl.when(is_retail)
        .then(pl.lit(1.0))
        .otherwise(_polars_maturity_adjustment_expr())
        .alias("maturity_adjustment")
    )

    # Step 6-9: Final calculations (pure Polars expressions)
    exposures = exposures.with_columns(
        [
            pl.lit(scaling_factor).alias("scaling_factor"),
            (
                pl.col("k")
                * 12.5
                * scaling_factor
                * pl.col("ead_final")
                * pl.col("maturity_adjustment")
            ).alias("rwa"),
            (pl.col("k") * 12.5 * scaling_factor * pl.col("maturity_adjustment")).alias(
                "risk_weight"
            ),
            (pl.col("pd_floored") * pl.col("lgd_floored") * pl.col("ead_final")).alias(
                "expected_loss"
            ),
        ]
    )

    # Step 10: Override for defaulted exposures (CRR Art. 153(1)(ii) / 154(1)(i))
    # Delegates to the single source of truth in adjustments.py to avoid divergence.
    from rwa_calc.engine.irb.adjustments import apply_defaulted_treatment

    exposures = apply_defaulted_treatment(exposures)

    return exposures
_correlation_expr_from_pd — src/rwa_calc/engine/irb/formulas.py:559
@cites("CRR Art. 153(2)")
def _correlation_expr_from_pd(
    pd_expr: pl.Expr,
    sme_threshold: float = 50.0,
    eur_gbp_rate: float = 0.8732,
    is_b31: bool = False,
    sme_turnover_threshold_m: float = 44.0,
) -> pl.Expr:
    """
    Shared correlation expression accepting an arbitrary PD expression.

    Supports all exposure classes with proper correlation formulas:
    - Corporate/Institution/Sovereign: PD-dependent (decay=50)
    - Retail mortgage: Fixed 0.15
    - QRRE: Fixed 0.04
    - Other retail: PD-dependent (decay=35)

    Includes:
    - SME firm size adjustment for corporates
    - FI scalar (1.25x) for large/unregulated financial sector entities (CRR Art. 153(2))

    Under Basel 3.1 (PRA PS1/26 Art. 153(4)), the SME correlation adjustment uses
    native GBP thresholds (44m/4.4m/39.6) directly on GBP turnover — no FX conversion.
    Under CRR, GBP turnover is converted to EUR via eur_gbp_rate, then clipped to
    EUR 5m-50m with denominator 45.

    Reads exposure_class, turnover_m, requires_fi_scalar columns from the LazyFrame.

    Args:
        pd_expr: Polars expression for the PD value to use
        sme_threshold: SME threshold in EUR millions (default 50.0, CRR only)
        eur_gbp_rate: EUR/GBP exchange rate for converting GBP turnover to EUR (CRR only)
        is_b31: If True, use GBP-native parameters per PRA PS1/26 Art. 153(4)
        sme_turnover_threshold_m: Basel 3.1 SME turnover threshold in GBP millions
            (default 44.0). Floor and range are derived: floor = threshold * 0.1,
            range = threshold - floor.
    """
    # Basel 3.1 SME correlation parameters derived from threshold
    _b31_sme_threshold_m = sme_turnover_threshold_m
    _b31_sme_floor_m = sme_turnover_threshold_m * 0.1
    _b31_sme_range = sme_turnover_threshold_m - _b31_sme_floor_m

    exp_class = pl.col("exposure_class").cast(pl.String).fill_null("CORPORATE").str.to_uppercase()

    # Pre-calculate decay denominators (constants)
    corporate_denom = 1.0 - math.exp(-50.0)
    retail_denom = 1.0 - math.exp(-35.0)

    # f(PD) for corporate (decay = 50)
    f_pd_corp = (1.0 - (-50.0 * pd_expr).exp()) / corporate_denom

    # f(PD) for retail (decay = 35)
    f_pd_retail = (1.0 - (-35.0 * pd_expr).exp()) / retail_denom

    # Corporate correlation: 0.12 × f(PD) + 0.24 × (1 - f(PD))
    r_corporate = 0.12 * f_pd_corp + 0.24 * (1.0 - f_pd_corp)

    # Retail other correlation: 0.03 × f(PD) + 0.16 × (1 - f(PD))
    r_retail_other = 0.03 * f_pd_retail + 0.16 * (1.0 - f_pd_retail)

    # SME adjustment for corporates: reduce correlation based on turnover
    # Cast to Float64 first to handle null dtype
    turnover_float = pl.col("turnover_m").cast(pl.Float64)

    if is_b31:
        # Basel 3.1: use GBP turnover directly with PRA-mandated thresholds.
        # turnover_m is sourced from sme_size_metric_gbp (= coalesce(annual_
        # revenue, total_assets)) so the S value automatically picks up the
        # assets fallback per PS1/26 Art. 153(4) third subparagraph.
        s_clamped = turnover_float.clip(_b31_sme_floor_m, _b31_sme_threshold_m)
        sme_adjustment = 0.04 * (1.0 - (s_clamped - _b31_sme_floor_m) / _b31_sme_range)
        has_valid_turnover = turnover_float.is_not_null() & turnover_float.is_finite()
        is_sme = has_valid_turnover & (turnover_float < _b31_sme_threshold_m)
    else:
        # CRR: convert GBP turnover to EUR, then apply EUR thresholds.
        # turnover_m is sourced from sme_size_metric_gbp (= coalesce(annual_
        # revenue, total_assets)) so the S value automatically picks up the
        # assets fallback per CRR Art. 153(4) third subparagraph.
        # turnover_eur = turnover_gbp / eur_gbp_rate
        # s = max(5, min(turnover_eur, 50))
        # adjustment = 0.04 × (1 - (s - 5) / 45)
        turnover_eur = turnover_float / eur_gbp_rate
        s_clamped = turnover_eur.clip(5.0, sme_threshold)
        sme_adjustment = 0.04 * (1.0 - (s_clamped - 5.0) / 45.0)
        has_valid_turnover = turnover_eur.is_not_null() & turnover_eur.is_finite()
        is_sme = has_valid_turnover & (turnover_eur < sme_threshold)

    # Corporate with SME adjustment (when turnover < threshold and is corporate)
    is_corporate = exp_class.str.contains("CORPORATE")

    r_corporate_with_sme = (
        pl.when(is_corporate & is_sme).then(r_corporate - sme_adjustment).otherwise(r_corporate)
    )

    # Build base correlation based on exposure class
    base_correlation = (
        pl.when(exp_class.str.contains("MORTGAGE") | exp_class.str.contains("RESIDENTIAL"))
        .then(pl.lit(0.15))
        .when(exp_class.str.contains("QRRE"))
        .then(pl.lit(0.04))
        .when(exp_class.str.contains("RETAIL"))
        .then(r_retail_other)
        .otherwise(r_corporate_with_sme)
    )

    # Apply FI scalar (1.25x) for large/unregulated financial sector entities
    # Per CRR Article 153(2)
    fi_scalar = (
        pl.when(pl.col("requires_fi_scalar").fill_null(False) == True)  # noqa: E712
        .then(pl.lit(1.25))
        .otherwise(pl.lit(1.0))
    )

    return base_correlation * fi_scalar
_capital_k_expr_from_params — src/rwa_calc/engine/irb/formulas.py:701
@cites("CRR Art. 153(1)")
def _capital_k_expr_from_params(
    pd_expr: pl.Expr,
    lgd_expr: pl.Expr,
    correlation_expr: pl.Expr,
) -> pl.Expr:
    """
    Shared K formula accepting arbitrary PD, LGD, and correlation expressions.

    K = LGD × N[(1-R)^(-0.5) × G(PD) + (R/(1-R))^(0.5) × G(0.999)] - PD × LGD

    Uses polars-normal-stats for normal_cdf and normal_ppf functions.

    Args:
        pd_expr: Polars expression for PD (will be clipped to [1e-10, 0.9999])
        lgd_expr: Polars expression for LGD
        correlation_expr: Polars expression for asset correlation
    """
    pd_safe = pd_expr.clip(1e-10, 0.9999)

    # G(PD) = inverse normal CDF of PD
    g_pd = normal_ppf(pd_safe)

    # Calculate conditional default probability terms
    one_minus_r = 1.0 - correlation_expr
    term1 = (1.0 / one_minus_r).sqrt() * g_pd
    term2 = (correlation_expr / one_minus_r).sqrt() * G_999

    # Conditional PD = N(term1 + term2)
    conditional_pd = normal_cdf(term1 + term2)

    # K = LGD × conditional_pd - PD × LGD
    k = lgd_expr * conditional_pd - pd_safe * lgd_expr

    # Floor at 0
    return pl.max_horizontal(k, pl.lit(0.0))
_double_default_multiplier_expr — src/rwa_calc/engine/irb/formulas.py:820
@cites("CRR Art. 153(3)")
def _double_default_multiplier_expr(guarantor_pd_expr: pl.Expr) -> pl.Expr:
    """
    Double default multiplier per CRR Art. 153(3) / Basel II para 284.

    K_dd = K_obligor × (0.15 + 160 × PD_g)

    The multiplier (0.15 + 160 × PD_g) reduces the capital charge by accounting
    for the joint probability that both obligor and guarantor default. For a
    high-quality guarantor (PD_g = 0.03%), the multiplier ≈ 0.198, providing
    ~80% capital relief vs standard substitution.

    Args:
        guarantor_pd_expr: Polars expression for the guarantor's PD (floored)

    Returns:
        Expression computing the double default multiplier (0.15 + 160 × PD_g)
    """
    return pl.lit(0.15) + pl.lit(160.0) * guarantor_pd_expr
calculate_double_default_k — src/rwa_calc/engine/irb/formulas.py:841
@cites("CRR Art. 153(3)")
def calculate_double_default_k(
    k_obligor: float,
    guarantor_pd: float,
) -> float:
    """
    Scalar double default K calculation.

    K_dd = K_obligor × (0.15 + 160 × PD_g)

    Args:
        k_obligor: Standard IRB K for the obligor (pre-guarantee)
        guarantor_pd: PD of the protection provider (floored)

    Returns:
        Capital requirement under double default treatment
    """
    multiplier = 0.15 + 160.0 * guarantor_pd
    return k_obligor * multiplier
calculate_correlation — src/rwa_calc/engine/irb/formulas.py:1030
@cites("CRR Art. 153(1)")
def calculate_correlation(
    pd: float,
    exposure_class: str,
    turnover_m: float | None = None,
    sme_threshold: float = 50.0,
    apply_fi_scalar: bool = False,
    eur_gbp_rate: float = 0.8732,
    is_b31: bool = False,
) -> float:
    """
    Scalar correlation calculation.

    Wrapper around _polars_correlation_expr() - uses the same implementation
    as vectorized processing.

    Args:
        pd: Probability of default
        exposure_class: Exposure class string
        turnover_m: Turnover in GBP millions (for SME adjustment)
        sme_threshold: SME threshold in EUR millions (default 50.0, CRR only)
        apply_fi_scalar: Whether to apply 1.25x FI scalar (CRR Art. 153(2))
                        for large/unregulated financial sector entities
        eur_gbp_rate: EUR/GBP exchange rate for converting GBP turnover to EUR (CRR only)
        is_b31: If True, use GBP-native parameters per PRA PS1/26 Art. 153(4)

    Returns:
        Asset correlation value
    """
    return _run_scalar_via_vectorized(
        {
            "pd_floored": pd,
            "exposure_class": exposure_class,
            "turnover_m": turnover_m,
            "requires_fi_scalar": apply_fi_scalar,
            "eur_gbp_rate": eur_gbp_rate,
            "is_b31": is_b31,
        },
        "correlation",
    )
calculate_k — src/rwa_calc/engine/irb/formulas.py:1072
@cites("CRR Art. 153(1)")
def calculate_k(pd: float, lgd: float, correlation: float) -> float:
    """Scalar capital requirement calculation.

    Wrapper around _polars_capital_k_expr() - uses the same implementation
    as vectorized processing.

    Args:
        pd: Probability of default (floored)
        lgd: Loss given default (floored)
        correlation: Asset correlation

    Returns:
        Capital requirement K value
    """
    # Handle edge cases that vectorized expression clips
    if pd >= 1.0:
        return lgd
    if pd <= 0:
        return 0.0

    return _run_scalar_via_vectorized(
        {
            "pd_floored": pd,
            "lgd_floored": lgd,
            "correlation": correlation,
        },
        "k",
    )
calculate_correlation — src/rwa_calc/engine/irb/transforms.py:370
@cites("CRR Art. 153(1)")
def calculate_correlation(
    lf: pl.LazyFrame,
    config: CalculationConfig,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """
    Calculate asset correlation using pure Polars expressions.

    Supports:
    - Corporate/Institution/Sovereign: PD-dependent (0.12-0.24)
    - Retail mortgage: Fixed 0.15
    - QRRE: Fixed 0.04
    - Other retail: PD-dependent (0.03-0.16)
    - SME adjustment for corporates (turnover converted from GBP to EUR)
    - FI scalar (1.25x) for large/unregulated financial sector entities

    Args:
        lf: IRB exposures frame
        config: Calculation configuration

    Returns:
        LazyFrame with correlation column
    """
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
    # B31 uses GBP-native thresholds (Art. 153(4)); CRR converts GBP→EUR via rate
    eur_gbp_rate = float(config.eur_gbp_rate)
    sme_turnover_m = (
        float(regulatory_threshold(resolved_pack, "sme_turnover_threshold", config.eur_gbp_rate))
        / 1_000_000
    )
    return lf.with_columns(
        _polars_correlation_expr(
            eur_gbp_rate=eur_gbp_rate,
            is_b31=resolved_pack.feature("irb_correlation_sme_gbp_native"),
            sme_turnover_threshold_m=sme_turnover_m,
        ).alias("correlation")
    )
calculate_k — src/rwa_calc/engine/irb/transforms.py:411
@cites("CRR Art. 153(1)")
def calculate_k(lf: pl.LazyFrame, config: CalculationConfig) -> pl.LazyFrame:
    """
    Calculate capital requirement (K) using pure Polars with polars-normal-stats.

    K = LGD × N[(1-R)^(-0.5) × G(PD) + (R/(1-R))^(0.5) × G(0.999)] - PD × LGD

    Args:
        lf: IRB exposures frame
        config: Calculation configuration

    Returns:
        LazyFrame with k column
    """
    return lf.with_columns(_polars_capital_k_expr().alias("k"))
calculate_branch — src/rwa_calc/engine/slotting/calculator.py:90
    @cites("CRR Art. 153(5)")
    def calculate_branch(
        self,
        exposures: pl.LazyFrame,
        config: CalculationConfig,
        *,
        errors: list[CalculationError] | None = None,
        pack: ResolvedRulepack | None = None,
    ) -> pl.LazyFrame:
        """
        Calculate Slotting RWA and Expected Loss on pre-filtered slotting-only rows.

        Computes risk weights (Art. 153(5)), RWA, supporting factors (Art. 501/501a),
        expected loss rates (Art. 158(6) Table B), and EL shortfall/excess for the
        portfolio EL summary.

        Args:
            exposures: Pre-filtered slotting rows only
            config: Calculation configuration
            errors: Optional error accumulator for data quality warnings

        Returns:
            LazyFrame with slotting RWA, expected_loss, el_shortfall, el_excess
        """
        exposures = (
            exposures.pipe(prepare_columns, config)
            .pipe(apply_slotting_weights, config, pack=pack)
            .pipe(calculate_rwa)
        )

        # Apply supporting factors (CRR Art. 501/501a) — same pattern as IRB
        exposures = self._apply_supporting_factors(exposures, config, errors=errors, pack=pack)

        exposures = exposures.pipe(apply_el_rates, config, pack=pack).pipe(
            compute_el_shortfall_excess, errors=errors
        )

        # Standardize output for aggregator
        schema = exposures.collect_schema()
        rwa_col = "rwa_final" if "rwa_final" in schema.names() else "rwa"
        return exposures.with_columns(
            pl.col("approach").alias("approach_applied"),
            pl.col(rwa_col).alias("rwa_final"),
        )
apply_slotting_weights — src/rwa_calc/engine/slotting/transforms.py:156
@cites("CRR Art. 153(5)")
def apply_slotting_weights(
    lf: pl.LazyFrame,
    config: CalculationConfig,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """Apply slotting risk weights based on framework, category, HVCRE flag, and maturity."""
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
    is_crr = not resolved_pack.feature("slotting_revised_tables")

    if is_crr:
        rw_expr = lookup_rw(
            col("slotting_category"),
            is_crr=True,
            is_hvcre=col("is_hvcre"),
            is_short=col("is_short_maturity"),
        )
    else:
        rw_expr = lookup_rw(
            col("slotting_category"),
            is_crr=False,
            is_hvcre=col("is_hvcre"),
            is_short=col("is_short_maturity"),
            is_preop=col("is_pre_operational"),
        )

    return lf.with_columns(risk_weight=rw_expr)
_build_is_defaulted_expr — src/rwa_calc/engine/stages/classify/attributes.py:491
@cites("CRR Art. 178")
@cites("CRR Art. 153")
def _build_is_defaulted_expr() -> pl.Expr:
    """Build per-exposure ``is_defaulted`` flag.

    Combines two explicit default signals so detection works at any
    granularity:

    - counterparty-level ``cp_default_status`` (propagates to all that
      counterparty's exposures);
    - explicit row-level ``is_defaulted`` carried on the loan/contingent
      parquet (lets a single-default exposure on an otherwise non-defaulted
      counterparty trigger the Art. 153(1)(ii) / 154(1)(i) defaulted
      treatment).

    Either one being true sets ``is_defaulted=True``.

    ``beel`` is deliberately **not** a trigger. PS1/26 Art. 181(1)(h)(ii)
    and CRR Art. 158(5) define BEEL only for defaulted exposures, but
    firms whose A-IRB models emit a BEEL-style value alongside LGD on
    performing exposures would otherwise see those rows silently
    reclassified as defaulted. The post-classification step
    ``_collect_beel_on_non_defaulted_warnings`` flags the contradictory
    combination (``is_defaulted=False ∧ beel>0``) as a DQ008 warning so
    the input contradiction is visible without changing routing.
    """
    cp_default = pl.col("cp_default_status") == True  # noqa: E712
    row_default = pl.col("is_defaulted").fill_null(False)
    return (cp_default | row_default).alias("is_defaulted")

CRR Art. 154 — Risk-weighted exposure amounts for retail exposures

apply_irb_formulas — src/rwa_calc/engine/irb/formulas.py:415
@cites("CRR Art. 151")
@cites("CRR Art. 153")
@cites("CRR Art. 154")
def apply_irb_formulas(
    exposures: pl.LazyFrame,
    config: CalculationConfig,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """
    Apply IRB formulas to exposures using pure Polars expressions.

    Uses polars-normal-stats for statistical functions (normal_cdf, normal_ppf),
    enabling full lazy evaluation, query optimization, and streaming.

    Expects columns: pd, lgd, ead_final, exposure_class
    Optional: maturity, turnover_m (for SME correlation adjustment)

    Adds columns: pd_floored, lgd_floored, correlation, k, maturity_adjustment,
                  scaling_factor, risk_weight, rwa, expected_loss

    Args:
        exposures: LazyFrame with IRB exposures
        config: Calculation configuration

    Returns:
        LazyFrame with IRB calculations added
    """
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
    scaling_factor = scalar_value(resolved_pack.scalar_param("irb_scaling_factor"))

    # Ensure calculator-internal derived columns exist (maturity / turnover_m
    # are produced by ``prepare_columns`` on the namespace path and are not
    # crm_exit contract columns).
    schema = exposures.collect_schema()
    schema_names = schema.names()
    if "maturity" not in schema_names:
        exposures = exposures.with_columns(pl.lit(2.5).alias("maturity"))
    if "turnover_m" not in schema_names:
        exposures = exposures.with_columns(pl.lit(None).cast(pl.Float64).alias("turnover_m"))

    # Step 1: Apply per-exposure-class PD floor (CRR: uniform, Basel 3.1: differentiated)
    pd_floor_expr = _pd_floor_expression(config, pack=resolved_pack)
    exposures = exposures.with_columns(
        pl.max_horizontal(pl.col("pd"), pd_floor_expr).alias("pd_floored")
    )

    # Step 2: Apply LGD floor (Basel 3.1 A-IRB only, CRR has no LGD floors)
    # LGD floors only apply to A-IRB own-estimate LGDs (CRE30.41).
    # F-IRB supervisory LGDs are regulatory values and don't need flooring.
    if resolved_pack.feature("airb_lgd_floor"):
        if "collateral_type" in schema_names:
            lgd_floor_expr = _lgd_floor_expression_with_collateral(
                config,
                has_seniority=True,
                has_exposure_class=True,
                pack=resolved_pack,
            )
        else:
            lgd_floor_expr = _lgd_floor_expression(
                config,
                has_seniority=True,
                has_exposure_class=True,
                pack=resolved_pack,
            )
        # Art. 164(4)(c) blended floor for retail with mixed collateral
        blended_expr = _lgd_floor_blended_expression(config, pack=resolved_pack)
        lgd_floor_expr = (
            pl.when(blended_expr.is_not_null()).then(blended_expr).otherwise(lgd_floor_expr)
        )
        is_airb = pl.col("is_airb").fill_null(False) if "is_airb" in schema_names else pl.lit(False)
        floored_lgd = pl.max_horizontal(pl.col("lgd"), lgd_floor_expr)
        exposures = exposures.with_columns(
            pl.when(is_airb).then(floored_lgd).otherwise(pl.col("lgd")).alias("lgd_floored")
        )
    else:
        exposures = exposures.with_columns(pl.col("lgd").alias("lgd_floored"))

    # Step 3: Calculate correlation using pure Polars expressions
    # B31 uses GBP-native thresholds (Art. 153(4)); CRR converts GBP→EUR via rate
    eur_gbp_rate = float(config.eur_gbp_rate)
    sme_turnover_m = (
        float(regulatory_threshold(resolved_pack, "sme_turnover_threshold", config.eur_gbp_rate))
        / 1_000_000
    )
    exposures = exposures.with_columns(
        _polars_correlation_expr(
            eur_gbp_rate=eur_gbp_rate,
            is_b31=resolved_pack.feature("irb_correlation_sme_gbp_native"),
            sme_turnover_threshold_m=sme_turnover_m,
        ).alias("correlation")
    )

    # Step 4: Calculate K using pure Polars with polars-normal-stats
    exposures = exposures.with_columns(_polars_capital_k_expr().alias("k"))

    # Step 5: Calculate maturity adjustment (only for non-retail)
    is_retail = (
        pl.col("exposure_class")
        .cast(pl.String)
        .fill_null("CORPORATE")
        .str.to_uppercase()
        .str.contains("RETAIL")
    )

    exposures = exposures.with_columns(
        pl.when(is_retail)
        .then(pl.lit(1.0))
        .otherwise(_polars_maturity_adjustment_expr())
        .alias("maturity_adjustment")
    )

    # Step 6-9: Final calculations (pure Polars expressions)
    exposures = exposures.with_columns(
        [
            pl.lit(scaling_factor).alias("scaling_factor"),
            (
                pl.col("k")
                * 12.5
                * scaling_factor
                * pl.col("ead_final")
                * pl.col("maturity_adjustment")
            ).alias("rwa"),
            (pl.col("k") * 12.5 * scaling_factor * pl.col("maturity_adjustment")).alias(
                "risk_weight"
            ),
            (pl.col("pd_floored") * pl.col("lgd_floored") * pl.col("ead_final")).alias(
                "expected_loss"
            ),
        ]
    )

    # Step 10: Override for defaulted exposures (CRR Art. 153(1)(ii) / 154(1)(i))
    # Delegates to the single source of truth in adjustments.py to avoid divergence.
    from rwa_calc.engine.irb.adjustments import apply_defaulted_treatment

    exposures = apply_defaulted_treatment(exposures)

    return exposures

CRR Art. 155 — Risk-weighted exposure amounts for equity exposures

get_equity_result_bundle — src/rwa_calc/engine/equity/calculator.py:211
    @cites("CRR Art. 133")
    @cites("CRR Art. 155")
    def get_equity_result_bundle(
        self,
        data: CRMAdjustedBundle,
        config: CalculationConfig,
        *,
        pack: ResolvedRulepack | None = None,
    ) -> EquityResultBundle:
        """
        Calculate equity RWA and return as a bundle.

        Args:
            data: CRM-adjusted exposures
            config: Calculation configuration

        Returns:
            EquityResultBundle with results and audit trail
        """
        errors: list[CalculationError] = []

        exposures = data.equity_exposures

        if exposures is None:
            empty_frame = pl.LazyFrame(
                {
                    "exposure_reference": pl.Series([], dtype=pl.String),
                    "equity_type": pl.Series([], dtype=pl.String),
                    "ead_final": pl.Series([], dtype=pl.Float64),
                    "risk_weight": pl.Series([], dtype=pl.Float64),
                    "rwa": pl.Series([], dtype=pl.Float64),
                }
            )
            return EquityResultBundle(
                results=empty_frame,
                calculation_audit=empty_frame,
                approach=EquityApproach.SA,
                errors=[],
            )

        approach = self._determine_approach(config, pack=pack)

        exposures = self._prepare_columns(exposures, config)
        exposures = self._resolve_look_through_rw(exposures, data.ciu_holdings, config, pack=pack)

        # Art. 155(3) PD/LGD computes RWEA inside the branch and bypasses both
        # the IRB Simple transitional floor and _calculate_rwa.
        if approach == EquityApproach.PD_LGD:
            exposures = self._apply_equity_weights_pd_lgd(exposures, config, pack=pack)
        else:
            if approach == EquityApproach.IRB_SIMPLE:
                exposures = self._apply_equity_weights_irb_simple(exposures, config)
            else:
                exposures = self._apply_equity_weights_sa(exposures, config, pack=pack)

            exposures = self._apply_transitional_floor(exposures, config, pack=pack)
            exposures = self._calculate_rwa(exposures)

        audit = self._build_audit(exposures, approach)

        return EquityResultBundle(
            results=exposures,
            calculation_audit=audit,
            approach=approach,
            errors=errors,
        )
_determine_approach — src/rwa_calc/engine/equity/calculator.py:277
    @cites("CRR Art. 155(3)")
    def _determine_approach(
        self,
        config: CalculationConfig,
        *,
        pack: ResolvedRulepack | None = None,
    ) -> EquityApproach:
        """
        Determine SA, IRB_SIMPLE, or PD_LGD based on config.

        Under Basel 3.1 (CRE20.58-62): IRB for equity is removed — all equity
        exposures must use SA treatment. The IRB Simple Risk Weight Method
        (Art. 155: 190%/290%/370%) and the PD/LGD approach (Art. 155(3)) are no
        longer available; the ``equity_pd_lgd`` flag is ignored.

        Under CRR: If the firm has IRB permissions (FIRB or AIRB) for any
        exposure class, equity uses either the Art. 155(3) PD/LGD approach (when
        ``config.equity_pd_lgd`` is True) or the Art. 155(2) IRB Simple approach.
        If SA-only, use Article 133 SA approach.

        Args:
            config: Calculation configuration

        Returns:
            EquityApproach.SA (Art. 133), EquityApproach.IRB_SIMPLE (Art. 155(2)),
            or EquityApproach.PD_LGD (Art. 155(3))
        """
        resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
        # Basel 3.1: IRB equity removed — all equity uses SA (CRE20.58-62).
        # The equity_pd_lgd flag is ignored under Basel 3.1.
        if not resolved_pack.feature("equity_irb_approaches_available"):
            return EquityApproach.SA

        # CRR: Check if firm has any IRB permissions beyond SA
        # If permissions dict is empty, it's SA-only
        # irb_permissions is derived non-None in CalculationConfig.__post_init__.
        if not config.irb_permissions.permissions:  # ty: ignore[unresolved-attribute]
            return EquityApproach.SA

        # Check if any exposure class has FIRB or AIRB permission
        for _exposure_class, approaches in config.irb_permissions.permissions.items():  # ty: ignore[unresolved-attribute]
            if ApproachType.FIRB in approaches or ApproachType.AIRB in approaches:
                # Art. 155(3): PD/LGD approach when the firm has elected it
                if config.equity_pd_lgd:
                    return EquityApproach.PD_LGD
                return EquityApproach.IRB_SIMPLE

        return EquityApproach.SA
_equity_holding_higher_of_rw — src/rwa_calc/engine/equity/calculator.py:495
    @cites("CRR Art. 155(2)")
    @cites("PS1/26, paragraph 4.8")
    @cites("PS1/26, paragraph 4.9")
    def _equity_holding_higher_of_rw(
        self, config: CalculationConfig, *, pack: ResolvedRulepack | None = None
    ) -> float | None:
        """Rules 4.7-4.8 higher-of RW for EQUITY-class CIU look-through holdings.

        Returns ``max(legacy Art. 155(2) "other equity" simple RW, Rule 4.2/4.3
        transitional SA RW)`` when the Basel 3.1 equity transitional regime is
        active for the reporting date, else ``None`` (no override — holdings keep
        the _DEFAULT_HOLDING_RW fallback).

        The transitional regime only applies to firms that held IRB equity
        permission, so ``equity_transitional.enabled`` (plus a transitional RW
        existing for the reporting date) is the gate.

        Per Rule 4.9-4.10, a firm that has irrevocably opted out of the
        transitional regime (``equity_transitional.opt_out``) suppresses the
        higher-of: ``None`` is returned so the holding falls back to the
        ``_DEFAULT_HOLDING_RW`` standard treatment. The opt-out applies jointly
        with the direct-equity transitional floor (Rule 4.9).

        References:
        - CRR Art. 155(2): IRB simple method equity RW ("other" = 370%).
        - PRA PS1/26 Rule 4.8: higher-of(Art. 155(2) simple, Rule 4.2/4.3 band).
        - PRA PS1/26 Rule 4.9-4.10: irrevocable joint opt-out suppresses higher-of.
        """
        if config.equity_transitional.opt_out:
            return None

        resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
        transitional_rw = _equity_transitional_rw(
            resolved_pack, config.reporting_date, is_higher_risk=False
        )
        if transitional_rw is None:
            return None

        legacy_simple_rw = _IRB_RW[EquityType.OTHER]
        return max(legacy_simple_rw, float(transitional_rw))
_net_short_positions — src/rwa_calc/engine/equity/calculator.py:736
    @cites("CRR Art. 155(2)")
    def _net_short_positions(self, exposures: pl.LazyFrame) -> pl.LazyFrame:
        """Net non-trading-book short positions against longs (CRR Art. 155(2)).

        Under the IRB Simple Risk Weight Method, short cash positions and
        derivatives held in the non-trading book may offset long positions in
        the *same individual stock* provided the offsetting short is an explicit
        hedge covering at least one year. Other short positions are treated as
        long with the relevant RW applied to their absolute value.

        Mechanics (LazyFrame-first, column-absence defensive):
        - Eligibility requires the optional inputs ``position_value`` and
          ``issuer_reference``; absent either, ``exposures`` is returned
          unchanged so production frames behave exactly as before.
        - A row is netting-eligible when it carries a non-null
          ``issuer_reference`` and ``is_explicitly_hedged`` is True (the boolean
          encodes "explicit hedge >= 1 year", ``CRR_EQUITY_NETTING_MIN_HEDGE_YEARS``).
        - Net long per issuer = ``max(0, sum(signed position_value))`` over the
          eligible rows. The surviving long row(s) carry the netted EAD pro-rata
          to their gross long value; absorbed shorts (and any rows whose group
          nets to <= 0) collapse to ``ead_final`` 0. Net-short residual is
          floored at 0 (out of scope here).
        - Ineligible rows keep their existing ``ead_final`` (the absolute-value
          ``fair_value``/``carrying_value``/``ead`` chain).
        """
        schema_names = exposures.collect_schema().names()
        if "position_value" not in schema_names or "issuer_reference" not in schema_names:
            return exposures

        is_hedged = (
            pl.col("is_explicitly_hedged").fill_null(False)
            if "is_explicitly_hedged" in schema_names
            else pl.lit(False)
        )
        # Eligible: a hedged position on a known issuer with a signed value.
        eligible = (
            pl.col("issuer_reference").is_not_null()
            & pl.col("position_value").is_not_null()
            & is_hedged
        )
        signed = pl.col("position_value").fill_null(0.0)
        gross_long = pl.when(eligible & (signed > 0)).then(signed).otherwise(pl.lit(0.0))

        # Per-issuer windowed aggregates over eligible rows only.
        net_long_per_issuer = (
            pl.when(eligible)
            .then(signed)
            .otherwise(pl.lit(0.0))
            .sum()
            .over("issuer_reference")
            .clip(lower_bound=0.0)
        )
        gross_long_per_issuer = gross_long.sum().over("issuer_reference")

        # Distribute the issuer's net long across its long rows pro-rata to
        # their gross long value; eligible shorts (and longs in a net-short or
        # fully-netted group) collapse to 0. Ineligible rows are untouched.
        share = (
            pl.when(gross_long_per_issuer > 0)
            .then(gross_long / gross_long_per_issuer)
            .otherwise(pl.lit(0.0))
        )
        netted_ead = net_long_per_issuer * share

        return exposures.with_columns(
            pl.when(eligible).then(netted_ead).otherwise(pl.col("ead_final")).alias("ead_final"),
        )
_apply_equity_weights_pd_lgd — src/rwa_calc/engine/equity/calculator.py:804
    @cites("CRR Art. 155(3)")
    @cites("CRR Art. 165")
    def _apply_equity_weights_pd_lgd(
        self,
        exposures: pl.LazyFrame,
        config: CalculationConfig,
        *,
        pack: ResolvedRulepack | None = None,
    ) -> pl.LazyFrame:
        """
        Apply the Article 155(3) PD/LGD equity approach.

        Risk-weighted exposure amounts are calculated with the corporate IRB K
        formula (Art. 153(1)) using supervisory parameters from Art. 165:

        - PD floor (Art. 165(1)): by equity sub-type —
            exchange-traded long-term / non-exchange regular cash flow -> 0.09%,
            exchange-traded (incl. short positions) -> 0.40%,
            all other equity -> 1.25%.
        - LGD (Art. 165(2)): 65% for sufficiently-diversified private equity
            (equity_type == "private_equity_diversified"), else 90%.
        - M (Art. 165(3)): fixed at 5 years.
        - Scaling (Art. 153): 1.06 for CRR.

        RWEA = K x 12.5 x scaling x MA x EAD, EL = PD x LGD x EAD. Per Art. 155(3)
        the result is capped at the individual-exposure level so that
        ``EL x 12.5 + RWEA <= EAD x 12.5`` (equivalently RWEA <= EAD x 12.5 - EL x 12.5,
        clamped at 0). A 1.5x scaling is applied to the risk weights where the
        institution lacks Art. 178 default-definition data
        (has_default_definition_info == False).

        The IRB Simple transitional floor (PRA Rules 4.1-4.10) does NOT apply —
        it is Simple-approach machinery.
        """
        resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
        scaling_factor = scalar_value(resolved_pack.scalar_param("irb_scaling_factor"))
        maturity = scalar_value(resolved_pack.scalar_param("equity_pd_lgd_maturity"))
        equity_lgd = formula_float_map(resolved_pack.formula("equity_pd_lgd_lgd"))
        lgd_diversified = equity_lgd["private_equity_diversified"]
        lgd_other = equity_lgd["other"]
        no_default_info_scaling = scalar_value(
            resolved_pack.scalar_param("equity_pd_lgd_no_default_info_scaling")
        )
        equity_pd_floors = formula_float_map(resolved_pack.formula("equity_pd_floors"))
        pd_floor_exchange_traded = equity_pd_floors["exchange_traded"]
        pd_floor_other = equity_pd_floors["other"]

        eq_type = pl.col("equity_type").str.to_lowercase()
        is_exchange_traded = pl.col("is_exchange_traded").fill_null(False)

        # Art. 165(1): PD floor by equity sub-type. Exchange-traded equity uses
        # the 0.40% Art. 165(1)(c) floor; all other equity uses 1.25% (165(1)(d)).
        pd_floored = (
            pl.when(is_exchange_traded | (eq_type == "exchange_traded") | (eq_type == "listed"))
            .then(pl.lit(pd_floor_exchange_traded))
            .otherwise(pl.lit(pd_floor_other))
        )

        # Art. 165(2): supervisory LGD — 65% diversified PE, else 90%.
        lgd = (
            pl.when(eq_type == "private_equity_diversified")
            .then(pl.lit(lgd_diversified))
            .otherwise(pl.lit(lgd_other))
        )

        # Corporate IRB K formula inputs (Art. 153(1)). The shared expressions
        # read exposure_class, turnover_m, requires_fi_scalar, maturity and
        # has_one_day_maturity_floor — set them to the corporate-equity defaults.
        exposures = exposures.with_columns(
            pl.lit(ExposureClass.CORPORATE.value.upper()).alias("exposure_class"),
            pl.lit(None).cast(pl.Float64).alias("turnover_m"),
            pl.lit(False).alias("requires_fi_scalar"),
            pl.lit(maturity).alias("maturity"),
            pl.lit(False).alias("has_one_day_maturity_floor"),
            pd_floored.alias("pd_floored"),
            lgd.alias("lgd"),
        )

        correlation = _correlation_expr_from_pd(
            pl.col("pd_floored"),
            eur_gbp_rate=float(config.eur_gbp_rate),
            is_b31=resolved_pack.feature("irb_correlation_sme_gbp_native"),
        )
        exposures = exposures.with_columns(correlation.alias("correlation"))

        k = _capital_k_expr_from_params(pl.col("pd_floored"), pl.col("lgd"), pl.col("correlation"))
        ma = _maturity_adjustment_expr_from_pd(pl.col("pd_floored"))
        exposures = exposures.with_columns(
            k.alias("k"),
            ma.alias("maturity_adjustment"),
            pl.lit(scaling_factor).alias("scaling_factor"),
        )

        # Art. 155(3): 1.5x scaling where the firm lacks Art. 178 default data.
        no_default_info = ~pl.col("has_default_definition_info").fill_null(False)
        rw_scaling = (
            pl.when(no_default_info).then(pl.lit(no_default_info_scaling)).otherwise(pl.lit(1.0))
        )

        # Base risk weight (Art. 153(1)): K x 12.5 x scaling x MA, then 1.5x where applicable.
        risk_weight = (
            pl.col("k") * 12.5 * pl.col("scaling_factor") * pl.col("maturity_adjustment")
        ) * rw_scaling

        exposures = exposures.with_columns(
            risk_weight.alias("risk_weight"),
            (pl.col("pd_floored") * pl.col("lgd") * pl.col("ead_final")).alias("expected_loss"),
        )

        # Uncapped RWEA = RW x EAD.
        rwea = pl.col("risk_weight") * pl.col("ead_final")
        # Art. 155(3) cap: EL x 12.5 + RWEA <= EAD x 12.5, i.e.
        # RWEA <= EAD x 12.5 - EL x 12.5, clamped at 0.
        rwea_cap = (pl.col("ead_final") * 12.5 - pl.col("expected_loss") * 12.5).clip(
            lower_bound=0.0
        )
        rwea_capped = pl.min_horizontal(rwea, rwea_cap)

        return exposures.with_columns(
            (rwea > rwea_cap).alias("equity_pd_lgd_cap_binds"),
            rwea_capped.alias("rwa"),
            rwea_capped.alias("rwa_final"),
        )

CRR Art. 161 — Loss Given Default (LGD)

apply_firb_supervisory_lgd_no_collateral — src/rwa_calc/engine/crm/collateral.py:449
@cites("CRR Art. 161")
def apply_firb_supervisory_lgd_no_collateral(
    exposures: pl.LazyFrame,
    config: CalculationConfig | None = None,
    *,
    pack: ResolvedRulepack | None = None,
    is_basel_3_1: bool = False,
) -> pl.LazyFrame:
    """
    Apply F-IRB supervisory LGD when no collateral is available.

    For F-IRB exposures without collateral, uses supervisory LGD values:
    - CRR Art. 161(1)(a): Senior unsecured 45%, Subordinated 75%
    - Basel 3.1 Art. 161(1)(a)/(aa): FSE senior 45%, non-FSE senior 40%, Sub 75%

    For A-IRB exposures under Basel 3.1:
    - LGD Modelling + insufficient data (Art. 169B): own lgd_unsecured as LGDU
    - Foundation election: supervisory LGDU (same as F-IRB)
    - LGD Modelling + sufficient data: keep modelled LGD unchanged

    Under CRR, A-IRB exposures always keep their modelled LGD.

    Args:
        exposures: Exposures with lgd_pre_crm
        config: CalculationConfig (optional, for AIRB collateral method)
        pack: Resolved rulepack; production threads the run's pack.
        is_basel_3_1: No-config bootstrap regime hint for _resolve_pack_for_lgd
            (direct unit-test path only). The regime BRANCHES read cited Features
            off the resolved pack, not this flag (S9h).

    Returns:
        Exposures with lgd_post_crm set for F-IRB (and qualifying A-IRB)
    """
    resolved_pack = _resolve_pack_for_lgd(pack, config, is_basel_3_1)
    # S9h: read the regime branches as honest cited Features off the same resolved
    # pack that supplies the LGD values. firb_fse_senior_lgd_split gates the FSE
    # 45/40 split; airb_lgd_collateral_method_applicable gates the B31 Art. 169A/169B
    # AIRB collateral-method branches (CRR AIRB is free-form).
    fse_senior_lgd_split = resolved_pack.feature("firb_fse_senior_lgd_split")
    airb_collateral_method_applies = resolved_pack.feature("airb_lgd_collateral_method_applicable")
    lgd_values = supervisory_lgd_values(resolved_pack)
    lgd_senior = lgd_values["unsecured"]
    lgd_subordinated = subordinated_unsecured_lgd(resolved_pack)

    # Add collateral-related columns with zero values for consistency
    exposures = exposures.with_columns(
        [
            pl.lit(0.0).alias("total_collateral_for_lgd"),
            pl.lit(0.0).alias("collateral_coverage_pct"),
        ]
    )

    # Determine LGD based on seniority for F-IRB
    schema_names = set(exposures.collect_schema().names())
    is_subordinated = (
        pl.col("seniority").fill_null("").str.to_lowercase().is_in(["subordinated", "junior"])
        if "seniority" in schema_names
        else pl.lit(False)
    )

    # Under Basel 3.1, FSE senior unsecured = 45% (Art. 161(1)(a));
    # non-FSE senior unsecured = 40% (Art. 161(1)(aa)).
    # Under CRR, all senior unsecured = 45% (no FSE distinction).
    if fse_senior_lgd_split and "cp_is_financial_sector_entity" in schema_names:
        lgd_senior_fse = lgd_values["unsecured_fse"]
        lgd_senior_expr = (
            pl.when(pl.col("cp_is_financial_sector_entity").fill_null(False))
            .then(pl.lit(lgd_senior_fse))
            .otherwise(pl.lit(lgd_senior))
        )
    else:
        lgd_senior_expr = pl.lit(lgd_senior)

    # --- Determine AIRB treatment (Art. 169A/169B) ---
    airb_method = config.airb_collateral_method if config else None
    is_airb = pl.col("approach") == ApproachType.AIRB.value

    if airb_collateral_method_applies and airb_method == AIRBCollateralMethod.FOUNDATION:
        # AIRB Foundation election: use supervisory LGDU (same as FIRB)
        uses_formula = (pl.col("approach") == ApproachType.FIRB.value) | is_airb
    elif (
        airb_collateral_method_applies
        and airb_method == AIRBCollateralMethod.LGD_MODELLING
        and "has_sufficient_collateral_data" in schema_names
    ):
        # Art. 169B: AIRB with insufficient data → use own lgd_unsecured
        _is_169b = is_airb & (
            pl.col("has_sufficient_collateral_data").fill_null(True) == False  # noqa: E712
        )
        own_lgdu = (
            pl.coalesce(pl.col("lgd_unsecured"), pl.col("lgd_pre_crm"))
            if "lgd_unsecured" in schema_names
            else pl.col("lgd_pre_crm")
        )
        # Build the expression: FIRB uses supervisory, AIRB 169B uses own, AIRB full keeps modelled
        exposures = exposures.with_columns(
            [
                pl.when((pl.col("approach") == ApproachType.FIRB.value) & is_subordinated)
                .then(pl.lit(lgd_subordinated))
                .when(pl.col("approach") == ApproachType.FIRB.value)
                .then(lgd_senior_expr)
                .when(_is_169b & is_subordinated)
                .then(pl.lit(lgd_subordinated))
                .when(_is_169b)
                .then(own_lgdu)
                .otherwise(pl.col("lgd_pre_crm"))
                .alias("lgd_post_crm"),
            ]
        )
        return exposures
    else:
        # CRR or no method: standard FIRB/AIRB split
        uses_formula = pl.col("approach") == ApproachType.FIRB.value

    exposures = exposures.with_columns(
        [
            pl.when(uses_formula & is_subordinated)
            .then(pl.lit(lgd_subordinated))  # Subordinated (same both frameworks)
            .when(uses_formula)
            .then(lgd_senior_expr)  # Senior unsecured (FSE-aware under B31)
            .otherwise(pl.col("lgd_pre_crm"))  # A-IRB or SA: keep existing
            .alias("lgd_post_crm"),
        ]
    )

    return exposures
_parametric_irb_risk_weight_expr — src/rwa_calc/engine/irb/formulas.py:867
@cites("CRR Art. 161")
def _parametric_irb_risk_weight_expr(
    pd_expr: pl.Expr,
    lgd: float | pl.Expr,
    scaling_factor: float = 1.0,
    eur_gbp_rate: float = 0.8732,
    is_b31: bool = False,
    sme_turnover_threshold_m: float = 44.0,
) -> pl.Expr:
    """
    Compute IRB risk weight from arbitrary PD expression and LGD.

    Used for Basel 3.1 parameter substitution (CRE22.70-85): when an IRB
    exposure is guaranteed by an F-IRB counterparty, the guaranteed portion
    uses the guarantor's PD and F-IRB supervisory LGD instead of the
    borrower's parameters.

    Reads exposure_class, turnover_m, maturity, requires_fi_scalar columns
    from the LazyFrame. PD and LGD are substituted externally.

    Args:
        pd_expr: Polars expression for the substituted PD (e.g. guarantor PD, floored)
        lgd: F-IRB supervisory LGD — either a fixed scalar (uniform LGD across
            all rows) or a Polars expression (per-row LGD selection, e.g. when
            seniority/FSE drives Art. 161(1)(a)/(aa)/(b) routing).
        scaling_factor: 1.06 for CRR, 1.0 for Basel 3.1
        eur_gbp_rate: EUR/GBP rate for SME turnover conversion (CRR only)
        is_b31: If True, use GBP-native SME parameters per PRA PS1/26 Art. 153(4)
        sme_turnover_threshold_m: Basel 3.1 SME turnover threshold in GBP millions

    Returns:
        Expression computing risk_weight = K × 12.5 × scaling × MA
    """
    correlation = _correlation_expr_from_pd(
        pd_expr,
        eur_gbp_rate=eur_gbp_rate,
        is_b31=is_b31,
        sme_turnover_threshold_m=sme_turnover_threshold_m,
    )
    lgd_expr = lgd if isinstance(lgd, pl.Expr) else pl.lit(lgd)
    k = _capital_k_expr_from_params(pd_expr, lgd_expr, correlation)
    ma = _maturity_adjustment_expr_from_pd(pd_expr)

    # Retail: no maturity adjustment (MA = 1.0)
    exp_class = pl.col("exposure_class").cast(pl.String).fill_null("CORPORATE").str.to_uppercase()
    is_retail = (
        exp_class.str.contains("RETAIL")
        | exp_class.str.contains("MORTGAGE")
        | exp_class.str.contains("QRRE")
    )
    ma = pl.when(is_retail).then(pl.lit(1.0)).otherwise(ma)

    return k * 12.5 * scaling_factor * ma
apply_guarantee_substitution — src/rwa_calc/engine/irb/guarantee.py:52
@cites("CRR Art. 161(3)")
def apply_guarantee_substitution(
    lf: pl.LazyFrame, config: CalculationConfig, *, pack: ResolvedRulepack | None = None
) -> pl.LazyFrame:
    """
    Apply guarantee substitution for IRB exposures with unfunded credit protection.

    Three methods depending on framework and guarantor approach:

    1. **SA risk weight substitution** (CRR Art. 215-217, Basel 3.1 SA guarantors):
       Guaranteed portion uses guarantor's SA risk weight.

    2. **Parameter substitution** (Basel 3.1 CRE22.70-85, IRB guarantors):
       Guaranteed portion recalculated using guarantor's PD and F-IRB supervisory
       LGD through the full IRB formula (K × 12.5 × scaling × MA).

    3. **Double default** (CRR Art. 153(3), 202-203, CRR only):
       K_dd = K_obligor × (0.15 + 160 × PD_guarantor). Requires A-IRB permission,
       corporate underlying, and eligible guarantor with internal PD. Provides
       lower capital charge than substitution for high-quality guarantors.

    The final RWA blends:
    - Unguaranteed portion: borrower's IRB RWA (pro-rated)
    - Guaranteed portion: guarantor's equivalent RWA (method-dependent)

    Args:
        lf: LazyFrame with IRB formula results
        config: Calculation configuration

    Returns:
        LazyFrame with guarantee-adjusted RWA
    """
    schema = lf.collect_schema()
    cols = schema.names()

    # Run-level sentinel gate: guarantor_entity_type is the one crm_exit
    # column still CONDITIONAL (inject=False) — present iff the CRM guarantee
    # sub-step ran. Keying on it keeps this machinery (and its derived audit
    # columns: rwa_irb_original, guarantor_rw*, guarantee_status, ...) off
    # unguaranteed runs; see contracts/edges.py. The guaranteed_portion check
    # covers direct (non-pipeline) invocation.
    if "guaranteed_portion" not in cols or "guarantor_entity_type" not in cols:
        return lf

    has_expected_loss = "expected_loss" in cols
    has_guarantor_pd = "guarantor_pd" in cols
    # PD substitution applies whenever the guarantor has an internal PD.
    # Per-row routing (IRB-derived RW vs SA-derived RW) is decided inside
    # _apply_parameter_substitution by guarantor_approach, which is itself
    # beneficiary-aware (set in engine/crm/guarantees.py). This covers both
    # CRR Art. 161(3) and Basel 3.1 CRE22.70-85 — only the F-IRB LGD differs.
    use_parameter_substitution = has_guarantor_pd

    # Store original IRB values before substitution (pre-CRM values)
    store_originals = [
        pl.col("rwa").alias("rwa_irb_original"),
        pl.col("risk_weight").alias("risk_weight_irb_original"),
        pl.col("risk_weight").alias("pre_crm_risk_weight"),
        pl.col("rwa").alias("pre_crm_rwa"),
    ]
    if has_expected_loss:
        store_originals.append(pl.col("expected_loss").alias("expected_loss_irb_original"))

    lf = lf.with_columns(store_originals)

    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack

    # --- Compute SA risk weight for guarantor (used for SA guarantors) ---
    lf = _compute_guarantor_rw_sa(lf, cols, config, pack=resolved_pack)

    # --- Basel 3.1 parameter substitution for IRB guarantors (CRE22.70-85) ---
    lf = _apply_parameter_substitution(
        lf, cols, config, use_parameter_substitution, pack=resolved_pack
    )

    # --- Double default treatment (CRR Art. 153(3), 202-203) ---
    lf = _apply_double_default(lf, cols, config, has_guarantor_pd, pack=resolved_pack)

    # --- Blend RWA and adjust expected loss ---
    ead_col = "ead_final" if "ead_final" in cols else "ead"

    # Check if guarantee is beneficial (guarantor RW < borrower IRB RW)
    # Non-beneficial guarantees should NOT be applied per CRR Art. 213
    lf = lf.with_columns(
        [
            pl.when(
                (pl.col("guaranteed_portion").fill_null(0) > 0)
                & (pl.col("guarantor_rw").is_not_null())
                & (pl.col("guarantor_rw") < pl.col("risk_weight_irb_original"))
            )
            .then(pl.lit(True))
            .otherwise(pl.lit(False))
            .alias("is_guarantee_beneficial"),
        ]
    )

    # Redistribute non-beneficial guarantee portions to beneficial guarantors.
    # For multi-guarantor exposures, non-beneficial guarantors' EAD is reallocated
    # to the most beneficial (lowest RW) guarantors using greedy fill.
    from rwa_calc.engine.crm.guarantees import redistribute_non_beneficial

    lf = redistribute_non_beneficial(lf)

    # Calculate blended RWA using substitution approach
    lf = lf.with_columns(
        [
            pl.when(
                (pl.col("guaranteed_portion").fill_null(0) > 0)
                & (pl.col("guarantor_rw").is_not_null())
                & (pl.col("is_guarantee_beneficial"))
            )
            .then(
                pl.col("rwa_irb_original")
                * (pl.col("unguaranteed_portion") / pl.col(ead_col)).fill_null(1.0)
                + pl.col("guaranteed_portion") * pl.col("guarantor_rw")
            )
            .otherwise(pl.col("rwa_irb_original"))
            .alias("rwa"),
        ]
    )

    # Calculate blended risk weight for reporting
    lf = lf.with_columns(
        [
            (pl.col("rwa") / pl.col(ead_col)).fill_null(0.0).alias("risk_weight"),
        ]
    )

    # Adjust expected loss for guaranteed portion
    if has_expected_loss:
        lf = _adjust_expected_loss(
            lf, config, ead_col, use_parameter_substitution, pack=resolved_pack
        )

    # Track guarantee status and method for reporting
    lf = _add_guarantee_status_columns(lf)

    # Drop internal tracking columns
    lf = lf.drop("_is_pd_substitution", "_is_dd_applied", "guarantor_rw_sa")

    return lf

CRR Art. 162 — Maturity

_maturity_adjustment_expr_from_pd — src/rwa_calc/engine/irb/formulas.py:750
@cites("CRR Art. 162(2)")
@cites("CRR Art. 162(3)")
def _maturity_adjustment_expr_from_pd(
    pd_expr: pl.Expr,
    maturity_floor: float = 1.0,
    maturity_cap: float = 5.0,
) -> pl.Expr:
    """
    Shared maturity adjustment expression accepting an arbitrary PD expression.

    b = (0.11852 - 0.05478 × ln(PD))²
    MA = (1 + (M - 2.5) × b) / (1 - 1.5 × b)

    Retail exposures should have MA=1.0 applied externally (this function
    does not check exposure class).

    Maturity is clipped to ``[maturity_floor, maturity_cap]`` (default
    [1y, 5y] per CRR Art. 162(2)) except where the input column
    ``has_one_day_maturity_floor`` is True. For carve-out rows the 1-year
    floor is suppressed and the actual maturity (down to 1 day) flows
    through to the formula. The 5-year cap is always applied.

    Callers must ensure ``has_one_day_maturity_floor`` exists on the frame
    (defaulting to False); the expression does no schema introspection.

    References:
        CRR Art. 153(1)(iii) — maturity adjustment formula
        CRR Art. 162(2) — 1y floor / 5y cap
        CRR Art. 162(3) — carve-out from the 1y floor for daily-margined SFTs
            and derivatives, margin lending, and short-term self-liquidating
            trade transactions
        BCBS CRE32.46 / CRE32.50 — equivalent Basel 3.1 references

    Args:
        pd_expr: Polars expression for PD
        maturity_floor: Minimum maturity in years (default 1.0). Suppressed
            for rows with ``has_one_day_maturity_floor=True``.
        maturity_cap: Maximum maturity in years (default 5.0). Always applied.
    """
    has_carve_out = pl.col("has_one_day_maturity_floor").fill_null(False)
    m_capped = pl.col("maturity").clip(upper_bound=maturity_cap)
    m = pl.when(has_carve_out).then(m_capped).otherwise(m_capped.clip(lower_bound=maturity_floor))

    # Safe PD for log calculation
    pd_safe = pd_expr.clip(lower_bound=1e-10)

    # b = (0.11852 - 0.05478 × ln(PD))²
    b = (0.11852 - 0.05478 * pd_safe.log()) ** 2

    # MA = (1 + (M - 2.5) × b) / (1 - 1.5 × b)
    return (1.0 + (m - 2.5) * b) / (1.0 - 1.5 * b)
_maturity_adjustment_expr_from_pd — src/rwa_calc/engine/irb/formulas.py:751
@cites("CRR Art. 162(2)")
@cites("CRR Art. 162(3)")
def _maturity_adjustment_expr_from_pd(
    pd_expr: pl.Expr,
    maturity_floor: float = 1.0,
    maturity_cap: float = 5.0,
) -> pl.Expr:
    """
    Shared maturity adjustment expression accepting an arbitrary PD expression.

    b = (0.11852 - 0.05478 × ln(PD))²
    MA = (1 + (M - 2.5) × b) / (1 - 1.5 × b)

    Retail exposures should have MA=1.0 applied externally (this function
    does not check exposure class).

    Maturity is clipped to ``[maturity_floor, maturity_cap]`` (default
    [1y, 5y] per CRR Art. 162(2)) except where the input column
    ``has_one_day_maturity_floor`` is True. For carve-out rows the 1-year
    floor is suppressed and the actual maturity (down to 1 day) flows
    through to the formula. The 5-year cap is always applied.

    Callers must ensure ``has_one_day_maturity_floor`` exists on the frame
    (defaulting to False); the expression does no schema introspection.

    References:
        CRR Art. 153(1)(iii) — maturity adjustment formula
        CRR Art. 162(2) — 1y floor / 5y cap
        CRR Art. 162(3) — carve-out from the 1y floor for daily-margined SFTs
            and derivatives, margin lending, and short-term self-liquidating
            trade transactions
        BCBS CRE32.46 / CRE32.50 — equivalent Basel 3.1 references

    Args:
        pd_expr: Polars expression for PD
        maturity_floor: Minimum maturity in years (default 1.0). Suppressed
            for rows with ``has_one_day_maturity_floor=True``.
        maturity_cap: Maximum maturity in years (default 5.0). Always applied.
    """
    has_carve_out = pl.col("has_one_day_maturity_floor").fill_null(False)
    m_capped = pl.col("maturity").clip(upper_bound=maturity_cap)
    m = pl.when(has_carve_out).then(m_capped).otherwise(m_capped.clip(lower_bound=maturity_floor))

    # Safe PD for log calculation
    pd_safe = pd_expr.clip(lower_bound=1e-10)

    # b = (0.11852 - 0.05478 × ln(PD))²
    b = (0.11852 - 0.05478 * pd_safe.log()) ** 2

    # MA = (1 + (M - 2.5) × b) / (1 - 1.5 × b)
    return (1.0 + (m - 2.5) * b) / (1.0 - 1.5 * b)
calculate_maturity_adjustment — src/rwa_calc/engine/irb/formulas.py:1103
@cites("CRR Art. 162")
def calculate_maturity_adjustment(
    pd: float,
    maturity: float,
    has_one_day_maturity_floor: bool = False,
    maturity_floor: float = 1.0,
    maturity_cap: float = 5.0,
) -> float:
    """Scalar maturity adjustment calculation.

    Wrapper around _polars_maturity_adjustment_expr() - uses the same
    implementation as vectorized processing.

    Args:
        pd: Probability of default (floored)
        maturity: Effective maturity in years
        has_one_day_maturity_floor: If True, the 1-year M floor is suppressed
            (CRR Art. 162(3) carve-out). The 5-year cap still applies.
        maturity_floor: Minimum maturity (default 1.0). Suppressed when
            ``has_one_day_maturity_floor=True``.
        maturity_cap: Maximum maturity (default 5.0). Always applied.

    Returns:
        Maturity adjustment factor
    """
    pd_safe = max(pd, 1e-10)

    return _run_scalar_via_vectorized(
        {
            "pd_floored": pd_safe,
            "maturity": maturity,
            "has_one_day_maturity_floor": has_one_day_maturity_floor,
        },
        "maturity_adjustment",
    )
calculate_maturity_adjustment — src/rwa_calc/engine/irb/transforms.py:428
@cites("CRR Art. 162")
def calculate_maturity_adjustment(lf: pl.LazyFrame, config: CalculationConfig) -> pl.LazyFrame:
    """
    Calculate maturity adjustment for non-retail exposures.

    MA = (1 + (M - 2.5) × b) / (1 - 1.5 × b)
    where b = (0.11852 - 0.05478 × ln(PD))²

    Retail exposures get MA = 1.0.

    Reads ``has_one_day_maturity_floor`` to gate the 1-year M floor
    (CRR Art. 162(3) carve-out); defaulted to False if absent.

    Args:
        lf: IRB exposures frame
        config: Calculation configuration

    Returns:
        LazyFrame with maturity_adjustment column
    """
    is_retail = (
        pl.col("exposure_class")
        .cast(pl.String)
        .fill_null("CORPORATE")
        .str.to_uppercase()
        .str.contains("RETAIL")
    )

    return lf.with_columns(
        pl.when(is_retail)
        .then(pl.lit(1.0))
        .otherwise(_polars_maturity_adjustment_expr())
        .alias("maturity_adjustment")
    )
_derive_ccr_sft_maturity_years — src/rwa_calc/engine/sft/fccm.py:232
@cites("CRR Art. 162")
@cites("PS1/26, paragraph 162")
def _derive_ccr_sft_maturity_years(
    *,
    remaining_years: float | None,
    under_mna: bool,
    qualifies_one_day_floor: bool,
    qualifies_mna_intermediate_floor: bool,
    pack: ResolvedRulepack,
) -> float | None:
    """Return the Art. 162 effective maturity M for one SFT netting set, or None.

    The carrier is the FULL M = ``clip(remaining_years, floor, 5.0)`` — the floor
    is a MINIMUM on the remaining maturity (Art. 162(2)(d)/(3)), never a fixed
    replacement value. For a long-dated MNA exposure the floor does not bite and
    M = ``remaining_years``. Returns ``None`` (the date-derived 1-year catch-all,
    Art. 162(2)(f) / PS1/26 162(2A)(f)) when the row is not under a master netting
    agreement or carries no maturity.

    Floor precedence (all sub-1y floors require the MNA precondition):

    - not under an MNA, or ``remaining_years is None`` -> ``None`` (1y catch-all).
    - ``qualifies_one_day_floor`` (the three conjunctive Art. 162(3) conditions —
      daily re-margin AND revaluation AND prompt-liquidation docs) -> the one-day
      (~1/365 y) floor.
    - else the 5BD repo/SFT floor (Art. 162(2)(d) / PS1/26 162(2A)(d)). Under B31
      the intermediate floor additionally requires the 162(2A)(c)/(d) daily
      documentation condition (gated by the
      ``mna_intermediate_floor_requires_daily_condition`` feature); without it the
      row falls to the 1-year catch-all (``None``). Under CRR the floor applies on
      MNA alone (the feature is off).

    Floors / feature are read from the RUN ``pack`` (not the module ``_PACK``) so
    the derivation is regime-correct.

    Args:
        remaining_years: Exact /365 fractional years to maturity, or None.
        under_mna: Art. 162(2) master-netting-agreement precondition.
        qualifies_one_day_floor: All three Art. 162(3) conditions hold.
        qualifies_mna_intermediate_floor: The B31 162(2A)(c)/(d) daily condition.
        pack: The resolved run rulepack supplying the cited maturity floors / gate.

    Returns:
        M as a float, or ``None`` for the date-derived 1-year catch-all.
    """
    if not under_mna or remaining_years is None:
        return None
    cap = 5.0
    if qualifies_one_day_floor:
        floor = float(pack.scalar_param("one_day_maturity_floor_years").value)
    else:
        requires_daily = pack.feature("mna_intermediate_floor_requires_daily_condition")
        intermediate_available = (not requires_daily) or qualifies_mna_intermediate_floor
        if not intermediate_available:
            return None
        floor = float(pack.scalar_param("irb_maturity_floor_repo_sft_years").value)
    return min(max(remaining_years, floor), cap)

CRR Art. 163 — Probability of default (PD)

_pd_floor_expression — src/rwa_calc/engine/irb/formulas.py:109
@cites("CRR Art. 163")
@cites("PS1/26, paragraph 163")
def _pd_floor_expression(
    config: CalculationConfig,
    *,
    has_transactor_col: bool = True,
    exposure_class_col: str = "exposure_class",
    transactor_col: str = "is_qrre_transactor",
    pack: ResolvedRulepack | None = None,
) -> pl.Expr:
    """
    Build Polars expression for per-exposure-class PD floor.

    Under CRR (Art. 163): Uniform 0.03% floor for all exposure classes.
    Under Basel 3.1 (CRE30.55): Differentiated floors:
        - Corporate/SME: 0.05%
        - Retail mortgage: 0.10% (Art. 163(1)(b))
        - QRRE transactors: 0.05%, revolvers: 0.10% (Art. 163(1)(c))
        - Retail other: 0.05%

    Args:
        config: Calculation configuration
        has_transactor_col: Whether the LazyFrame has the transactor column.
            When True (pipeline path), uses per-row transactor/revolver distinction.
            When False (isolated expressions), defaults to conservative revolver floor.
        exposure_class_col: Name of the column to read the exposure class from.
            Defaults to ``exposure_class`` (the borrower's class). For guarantor
            PD substitution (CRR Art. 161(3) / B31 CRE22.70-85, Art. 160(4)),
            pass ``guarantor_exposure_class`` so the floor reads the guarantor's
            own class — the guaranteed portion is treated as a direct exposure
            to the guarantor, so the guarantor's class floor governs.
        transactor_col: Name of the QRRE transactor flag column. For guarantor
            PD floors this is normally not relevant (guarantors are typically
            not QRRE), but the parameter is exposed for symmetry with
            ``exposure_class_col``.

    Returns a Polars expression evaluating to the per-row PD floor value.
    """
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
    floors = formula_float_map(resolved_pack.formula("pd_floors"))

    # Optimisation: if all floors are the same (CRR case), return a scalar
    all_values = set(floors.values())
    if len(all_values) == 1:
        return pl.lit(all_values.pop())

    # Basel 3.1: differentiated floors by exposure class
    exp_class = pl.col(exposure_class_col).cast(pl.String).fill_null("CORPORATE").str.to_uppercase()

    # QRRE transactor/revolver distinction (CRE30.55):
    # Transactors (repay in full each period) get 0.03% floor;
    # revolvers (carry balance) get 0.10% floor.
    if has_transactor_col:
        qrre_floor = (
            pl.when(pl.col(transactor_col).fill_null(False))
            .then(pl.lit(floors["retail_qrre_transactor"]))
            .otherwise(pl.lit(floors["retail_qrre_revolver"]))
        )
    else:
        # Conservative default: revolver floor (0.10% under Basel 3.1)
        qrre_floor = pl.lit(floors["retail_qrre_revolver"])

    sovereign_value = ExposureClass.CENTRAL_GOVT_CENTRAL_BANK.value.upper()
    institution_value = ExposureClass.INSTITUTION.value.upper()

    return (
        pl.when(exp_class.str.contains("QRRE"))
        .then(qrre_floor)
        .when(exp_class.str.contains("MORTGAGE") | exp_class.str.contains("RESIDENTIAL"))
        .then(pl.lit(floors["retail_mortgage"]))
        .when(exp_class.str.contains("RETAIL"))
        .then(pl.lit(floors["retail_other"]))
        .when(exp_class == "CORPORATE_SME")
        .then(pl.lit(floors["corporate_sme"]))
        .when(exp_class == sovereign_value)
        .then(pl.lit(floors["sovereign"]))
        .when(exp_class == institution_value)
        .then(pl.lit(floors["institution"]))
        .otherwise(pl.lit(floors["corporate"]))
    )
apply_pd_floor — src/rwa_calc/engine/irb/transforms.py:276
@cites("CRR Art. 163")
@cites("PS1/26, paragraph 163")
def apply_pd_floor(
    lf: pl.LazyFrame, config: CalculationConfig, *, pack: ResolvedRulepack | None = None
) -> pl.LazyFrame:
    """
    Apply PD floor based on configuration.

    CRR (Art. 163): 0.03% for all classes
    Basel 3.1 (CRE30.55): Differentiated by class
        - Corporate/SME: 0.05%
        - Retail mortgage: 0.05%
        - QRRE revolvers: 0.10%, transactors: 0.03%
        - Retail other: 0.05%

    Args:
        lf: IRB exposures frame
        config: Calculation configuration
        pack: Resolved rulepack (falls back to ``config`` when omitted)

    Returns:
        LazyFrame with pd_floored column
    """
    pd_floor_expr = _pd_floor_expression(config, pack=pack)
    return lf.with_columns(pl.max_horizontal(pl.col("pd"), pd_floor_expr).alias("pd_floored"))

CRR Art. 164 — Loss Given Default (LGD)

_lgd_floor_expression — src/rwa_calc/engine/irb/formulas.py:191
@cites("CRR Art. 164")
@cites("PS1/26, paragraph 164")
def _lgd_floor_expression(
    config: CalculationConfig,
    *,
    has_seniority: bool = False,
    has_exposure_class: bool = False,
    pack: ResolvedRulepack | None = None,
) -> pl.Expr:
    """
    Build Polars expression for LGD floor (no collateral_type column).

    Under CRR: No LGD floors (returns 0.0).
    Under Basel 3.1: Differentiated floors for A-IRB by exposure class:
        Corporate (Art. 161(5)): 25% unsecured (senior & subordinated alike)
        Retail (Art. 164(4)):
            - retail_mortgage: 5% (assumed RRE-secured)
            - retail_qrre: 50% (Art. 164(4)(b)(i))
            - retail_other: 30% (Art. 164(4)(b)(ii))

    Without exposure_class, falls back to seniority-based logic (conservative).
    Without either, defaults to 25% unsecured floor.

    Returns a Polars expression evaluating to the per-row LGD floor value.
    """
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
    if not resolved_pack.feature("airb_lgd_floor"):
        return pl.lit(0.0)

    floors = formula_float_map(resolved_pack.formula("lgd_floors"))

    if has_exposure_class:
        # Route by exposure class — retail gets Art. 164(4) floors
        exp_class = pl.col("exposure_class").cast(pl.String).str.to_lowercase()
        return (
            pl.when(exp_class.is_in(["retail_mortgage"]))
            .then(pl.lit(floors["retail_rre"]))  # 5% Art. 164(4)(a)
            .when(exp_class.is_in(["retail_qrre"]))
            .then(pl.lit(floors["retail_qrre_unsecured"]))  # 50% Art. 164(4)(b)(i)
            .when(exp_class.is_in(["retail_other"]))
            .then(pl.lit(floors["retail_other_unsecured"]))  # 30% Art. 164(4)(b)(ii)
            .otherwise(pl.lit(floors["unsecured"]))  # 25% Art. 161(5)
        )

    if has_seniority:
        # Fallback without exposure_class: corporate A-IRB applies a single 25%
        # unsecured floor regardless of seniority (Art. 161(5)). The 50%
        # subordinated_unsecured value is the F-IRB supervisory LGD per
        # Art. 161(1)(b), not an A-IRB floor — do not branch on seniority here.
        return pl.lit(floors["unsecured"])

    # Default to unsecured floor (25%) — most conservative for senior
    return pl.lit(floors["unsecured"])
apply_lgd_floor — src/rwa_calc/engine/irb/transforms.py:303
@cites("CRR Art. 164")
@cites("PS1/26, paragraph 164")
def apply_lgd_floor(
    lf: pl.LazyFrame, config: CalculationConfig, *, pack: ResolvedRulepack | None = None
) -> pl.LazyFrame:
    """
    Apply LGD floor for Basel 3.1 A-IRB exposures.

    Uses lgd_input (which contains collateral-adjusted LGD for F-IRB)
    as the base for flooring.

    CRR: No LGD floor (A-IRB models LGD freely)
    Basel 3.1: Differentiated floors by collateral type and exposure class:
        - Corporate unsecured (senior & subordinated): 25% (Art. 161(5))
        - Retail QRRE unsecured: 50% (Art. 164(4)(b)(i))
        - Financial: 0%, Receivables: 10%
        - RRE: 10%, CRE: 10%, Other physical: 15%

    LGD floors only apply to A-IRB own-estimate LGDs. F-IRB supervisory
    LGDs are regulatory values and don't need flooring.

    Args:
        lf: IRB exposures frame
        config: Calculation configuration
        pack: Resolved rulepack (falls back to ``config`` when omitted)

    Returns:
        LazyFrame with lgd_floored column
    """
    schema = lf.collect_schema()
    schema_names = schema.names()
    lgd_col = "lgd_input" if "lgd_input" in schema_names else "lgd"
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack

    if resolved_pack.feature("airb_lgd_floor"):
        if "collateral_type" in schema_names:
            lgd_floor_expr = _lgd_floor_expression_with_collateral(
                config,
                has_seniority=True,
                has_exposure_class=True,
                pack=resolved_pack,
            )
        else:
            lgd_floor_expr = _lgd_floor_expression(
                config,
                has_seniority=True,
                has_exposure_class=True,
                pack=resolved_pack,
            )

        # Art. 164(4)(c) blended floor for retail with mixed collateral
        # Use blended floor where applicable (retail_other/qrre with collateral),
        # fall back to single-type floor otherwise
        blended_expr = _lgd_floor_blended_expression(config, pack=resolved_pack)
        lgd_floor_expr = (
            pl.when(blended_expr.is_not_null()).then(blended_expr).otherwise(lgd_floor_expr)
        )

        # LGD floors only apply to A-IRB (CRE30.41); F-IRB uses supervisory LGD
        is_airb = pl.col("is_airb").fill_null(False) if "is_airb" in schema_names else pl.lit(False)
        floored_lgd = pl.max_horizontal(pl.col(lgd_col), lgd_floor_expr)
        return lf.with_columns(
            pl.when(is_airb).then(floored_lgd).otherwise(pl.col(lgd_col)).alias("lgd_floored")
        )
    return lf.with_columns(pl.col(lgd_col).alias("lgd_floored"))

CRR Art. 165 — Equity exposures subject to the PD/LGD method

_apply_equity_weights_pd_lgd — src/rwa_calc/engine/equity/calculator.py:805
    @cites("CRR Art. 155(3)")
    @cites("CRR Art. 165")
    def _apply_equity_weights_pd_lgd(
        self,
        exposures: pl.LazyFrame,
        config: CalculationConfig,
        *,
        pack: ResolvedRulepack | None = None,
    ) -> pl.LazyFrame:
        """
        Apply the Article 155(3) PD/LGD equity approach.

        Risk-weighted exposure amounts are calculated with the corporate IRB K
        formula (Art. 153(1)) using supervisory parameters from Art. 165:

        - PD floor (Art. 165(1)): by equity sub-type —
            exchange-traded long-term / non-exchange regular cash flow -> 0.09%,
            exchange-traded (incl. short positions) -> 0.40%,
            all other equity -> 1.25%.
        - LGD (Art. 165(2)): 65% for sufficiently-diversified private equity
            (equity_type == "private_equity_diversified"), else 90%.
        - M (Art. 165(3)): fixed at 5 years.
        - Scaling (Art. 153): 1.06 for CRR.

        RWEA = K x 12.5 x scaling x MA x EAD, EL = PD x LGD x EAD. Per Art. 155(3)
        the result is capped at the individual-exposure level so that
        ``EL x 12.5 + RWEA <= EAD x 12.5`` (equivalently RWEA <= EAD x 12.5 - EL x 12.5,
        clamped at 0). A 1.5x scaling is applied to the risk weights where the
        institution lacks Art. 178 default-definition data
        (has_default_definition_info == False).

        The IRB Simple transitional floor (PRA Rules 4.1-4.10) does NOT apply —
        it is Simple-approach machinery.
        """
        resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
        scaling_factor = scalar_value(resolved_pack.scalar_param("irb_scaling_factor"))
        maturity = scalar_value(resolved_pack.scalar_param("equity_pd_lgd_maturity"))
        equity_lgd = formula_float_map(resolved_pack.formula("equity_pd_lgd_lgd"))
        lgd_diversified = equity_lgd["private_equity_diversified"]
        lgd_other = equity_lgd["other"]
        no_default_info_scaling = scalar_value(
            resolved_pack.scalar_param("equity_pd_lgd_no_default_info_scaling")
        )
        equity_pd_floors = formula_float_map(resolved_pack.formula("equity_pd_floors"))
        pd_floor_exchange_traded = equity_pd_floors["exchange_traded"]
        pd_floor_other = equity_pd_floors["other"]

        eq_type = pl.col("equity_type").str.to_lowercase()
        is_exchange_traded = pl.col("is_exchange_traded").fill_null(False)

        # Art. 165(1): PD floor by equity sub-type. Exchange-traded equity uses
        # the 0.40% Art. 165(1)(c) floor; all other equity uses 1.25% (165(1)(d)).
        pd_floored = (
            pl.when(is_exchange_traded | (eq_type == "exchange_traded") | (eq_type == "listed"))
            .then(pl.lit(pd_floor_exchange_traded))
            .otherwise(pl.lit(pd_floor_other))
        )

        # Art. 165(2): supervisory LGD — 65% diversified PE, else 90%.
        lgd = (
            pl.when(eq_type == "private_equity_diversified")
            .then(pl.lit(lgd_diversified))
            .otherwise(pl.lit(lgd_other))
        )

        # Corporate IRB K formula inputs (Art. 153(1)). The shared expressions
        # read exposure_class, turnover_m, requires_fi_scalar, maturity and
        # has_one_day_maturity_floor — set them to the corporate-equity defaults.
        exposures = exposures.with_columns(
            pl.lit(ExposureClass.CORPORATE.value.upper()).alias("exposure_class"),
            pl.lit(None).cast(pl.Float64).alias("turnover_m"),
            pl.lit(False).alias("requires_fi_scalar"),
            pl.lit(maturity).alias("maturity"),
            pl.lit(False).alias("has_one_day_maturity_floor"),
            pd_floored.alias("pd_floored"),
            lgd.alias("lgd"),
        )

        correlation = _correlation_expr_from_pd(
            pl.col("pd_floored"),
            eur_gbp_rate=float(config.eur_gbp_rate),
            is_b31=resolved_pack.feature("irb_correlation_sme_gbp_native"),
        )
        exposures = exposures.with_columns(correlation.alias("correlation"))

        k = _capital_k_expr_from_params(pl.col("pd_floored"), pl.col("lgd"), pl.col("correlation"))
        ma = _maturity_adjustment_expr_from_pd(pl.col("pd_floored"))
        exposures = exposures.with_columns(
            k.alias("k"),
            ma.alias("maturity_adjustment"),
            pl.lit(scaling_factor).alias("scaling_factor"),
        )

        # Art. 155(3): 1.5x scaling where the firm lacks Art. 178 default data.
        no_default_info = ~pl.col("has_default_definition_info").fill_null(False)
        rw_scaling = (
            pl.when(no_default_info).then(pl.lit(no_default_info_scaling)).otherwise(pl.lit(1.0))
        )

        # Base risk weight (Art. 153(1)): K x 12.5 x scaling x MA, then 1.5x where applicable.
        risk_weight = (
            pl.col("k") * 12.5 * pl.col("scaling_factor") * pl.col("maturity_adjustment")
        ) * rw_scaling

        exposures = exposures.with_columns(
            risk_weight.alias("risk_weight"),
            (pl.col("pd_floored") * pl.col("lgd") * pl.col("ead_final")).alias("expected_loss"),
        )

        # Uncapped RWEA = RW x EAD.
        rwea = pl.col("risk_weight") * pl.col("ead_final")
        # Art. 155(3) cap: EL x 12.5 + RWEA <= EAD x 12.5, i.e.
        # RWEA <= EAD x 12.5 - EL x 12.5, clamped at 0.
        rwea_cap = (pl.col("ead_final") * 12.5 - pl.col("expected_loss") * 12.5).clip(
            lower_bound=0.0
        )
        rwea_capped = pl.min_horizontal(rwea, rwea_cap)

        return exposures.with_columns(
            (rwea > rwea_cap).alias("equity_pd_lgd_cap_binds"),
            rwea_capped.alias("rwa"),
            rwea_capped.alias("rwa_final"),
        )

CRR Art. 166 — Exposures to corporates, institutions, central governments and central banks and retail exposures

_firb_ccf_for_col — src/rwa_calc/engine/ccf.py:211
@cites("CRR Art. 166")
def _firb_ccf_for_col(risk_type_col: str = "risk_type") -> pl.Expr:
    """Polars expression for CRR F-IRB CCFs (Art. 166(8) + (10)).

    Implements both F-IRB CCF clauses of CRR Article 166:

    Art. 166(8) bespoke CCFs (is_obs_commitment=True, matching commitment):
        (a) UCC credit lines (LR) -> 0%; (b) short-term trade LCs
        (MLR + is_short_term_trade_lc) -> 20%; (d) other credit lines /
        NIFs / RUFs (MR/MLR/OC commitments) -> 75%.
    Art. 166(10) residual fallback (is_obs_commitment=False):
        (a) full risk -> 100%; (b) medium -> 50%; (c) medium/low -> 20%;
        (d) low -> 0%.

    FR/FRC and LR converge under either path; the Art. 166(8)(b) trade-LC
    carve-out wins over the issued/commitment split. Values come from the
    rulepack (``firb_obs_fallback_ccf`` lookup + the bespoke scalars).
    """
    canonical = _normalize_risk_type(risk_type_col)
    is_commitment = pl.col("is_obs_commitment").fill_null(True)
    is_trade_lc = pl.col("is_short_term_trade_lc").fill_null(False)
    is_mlr = canonical == "MLR"
    # MR_ISSUED (CRR Annex I Row 3 issued OBS items) mirrors MR exactly: it
    # rides the same Art. 166(8)(d) commitment / Art. 166(10)(b) issued split,
    # so it never diverges to the otherwise default (P2.30).
    is_mr_or_oc = canonical.is_in(["MR", "MR_ISSUED", "OC"])
    return (
        # FR/FRC -> 100% under both Art. 166(8) general and Art. 166(10)(a)
        pl.when(canonical.is_in(["FR", "FRC"]))
        .then(pl.lit(_FIRB_OBS_FALLBACK_MAP["FR"]))
        # LR -> 0% under both Art. 166(8)(a) and Art. 166(10)(d)
        .when(canonical == "LR")
        .then(pl.lit(_FIRB_OBS_FALLBACK_MAP["LR"]))
        # Art. 166(8)(b): short-term trade LC carve-out wins over both buckets
        .when(is_mlr & is_trade_lc)
        .then(pl.lit(_FIRB_TRADE_LC_CCF))
        # Art. 166(8)(d): credit lines / NIFs / RUFs -> 75%
        .when(is_commitment & (is_mr_or_oc | is_mlr))
        .then(pl.lit(_FIRB_CREDIT_LINE_CCF))
        # Art. 166(10)(b): MR / OC issued items -> 50%
        .when(is_mr_or_oc)
        .then(pl.lit(_FIRB_OBS_FALLBACK_MAP["MR"]))
        # Art. 166(10)(c): MLR issued items -> 20%
        .when(is_mlr)
        .then(pl.lit(_FIRB_OBS_FALLBACK_MAP["MLR"]))
        # Conservative MR-equivalent fallback for unrecognised risk_type values
        .otherwise(pl.lit(_SA_CCF_DEFAULT))
    )
apply_ccf — src/rwa_calc/engine/ccf.py:287
    @cites("CRR Art. 111")
    @cites("CRR Art. 166")
    def apply_ccf(
        self,
        exposures: pl.LazyFrame,
        config: CalculationConfig,
        *,
        pack: ResolvedRulepack | None = None,
    ) -> pl.LazyFrame:
        """
        Apply CCF to calculate EAD for off-balance sheet exposures.

        CCF determination follows CRR Art. 111 categories based on risk_type:
        - SA: FR=100%, MR=50%, MLR=20%, LR=0%
        - F-IRB Art. 166(8)(d): MR/MLR/OC commitments (credit lines / NIFs / RUFs)
          when ``is_obs_commitment=True`` -> 75%
        - F-IRB Art. 166(10) fallback: issued OBS items (``is_obs_commitment=False``)
          -> 100% FR / 50% MR / 20% MLR / 0% LR
        - F-IRB Art. 166(8)(b): MLR with ``is_short_term_trade_lc=True`` -> 20%
        - A-IRB CRR: Uses ccf_modelled if provided, otherwise falls back to SA
        - A-IRB B31: Own CCF only for revolving (non-100% SA); else SA CCF (Art. 166D)
        - Art. 111(1)(c): When underlying_risk_type is specified, CCF is capped
          at the lower of the commitment's CCF and the underlying OBS item's CCF

        Args:
            exposures: Exposures with nominal_amount, risk_type, and approach columns
            config: Calculation configuration

        Returns:
            LazyFrame with ead_from_ccf and ccf columns added
        """
        schema = exposures.collect_schema()
        names = schema.names()
        original_has_risk_type = "risk_type" in names
        original_has_underlying = "underlying_risk_type" in names
        original_has_interest = "interest" in names
        has_provision_cols = "nominal_after_provision" in names and "provision_on_drawn" in names

        exposures, added_cols = self._ensure_columns(exposures, names, has_provision_cols)
        exposures = self._compute_ccf(exposures, config, pack=pack)
        exposures = self._compute_ead(exposures, has_provision_cols, config, pack=pack)
        exposures = self._build_audit_trail(
            exposures, original_has_risk_type, original_has_underlying, original_has_interest
        )

        # Clean up temp and default-populated columns
        return exposures.drop(
            "_sa_ccf_from_risk_type",
            "_firb_ccf_from_risk_type",
            "_nominal_is_zero",
            *added_cols,
        )

CRR Art. 178 — Default of an obligor

_build_is_defaulted_expr — src/rwa_calc/engine/stages/classify/attributes.py:490
@cites("CRR Art. 178")
@cites("CRR Art. 153")
def _build_is_defaulted_expr() -> pl.Expr:
    """Build per-exposure ``is_defaulted`` flag.

    Combines two explicit default signals so detection works at any
    granularity:

    - counterparty-level ``cp_default_status`` (propagates to all that
      counterparty's exposures);
    - explicit row-level ``is_defaulted`` carried on the loan/contingent
      parquet (lets a single-default exposure on an otherwise non-defaulted
      counterparty trigger the Art. 153(1)(ii) / 154(1)(i) defaulted
      treatment).

    Either one being true sets ``is_defaulted=True``.

    ``beel`` is deliberately **not** a trigger. PS1/26 Art. 181(1)(h)(ii)
    and CRR Art. 158(5) define BEEL only for defaulted exposures, but
    firms whose A-IRB models emit a BEEL-style value alongside LGD on
    performing exposures would otherwise see those rows silently
    reclassified as defaulted. The post-classification step
    ``_collect_beel_on_non_defaulted_warnings`` flags the contradictory
    combination (``is_defaulted=False ∧ beel>0``) as a DQ008 warning so
    the input contradiction is visible without changing routing.
    """
    cp_default = pl.col("cp_default_status") == True  # noqa: E712
    row_default = pl.col("is_defaulted").fill_null(False)
    return (cp_default | row_default).alias("is_defaulted")

CRR Art. 194 — Principles governing the eligibility of credit risk mitigation techniques

get_crm_unified_bundle — src/rwa_calc/engine/crm/processor.py:492
    @cites("CRR Art. 194")
    def get_crm_unified_bundle(
        self,
        data: ClassifiedExposuresBundle,
        config: CalculationConfig,
        *,
        pack: ResolvedRulepack | None = None,
    ) -> CRMAdjustedBundle:
        """
        Apply CRM on the unified exposure frame (single-pass pipeline).

        Runs the full CRM chain — look-through, provisions, CCF, collateral
        (Comprehensive, plus FCSM precompute under a Simple Method election),
        life insurance, guarantees — and returns the unified LazyFrame for
        the calculators' approach split. Laziness is strictly intra-stage:
        the two sanctioned checkpoints (``crm_post_ead``,
        ``crm_pre_guarantee_unified``) and the ``crm_exit`` stage edge keep
        the plan shallow.

        Args:
            data: Classified exposures from classifier
            config: Calculation configuration
            pack: Resolved rulepack for the run's regime/date (Phase 5 — the
                source of regulatory values, e.g. the Art. 222 FCSM floors).
                Production passes the orchestrator's pack; direct callers may
                omit it, in which case sub-steps resolve one from ``config``.

        Returns:
            CRMAdjustedBundle with all exposures in the unified frame
        """
        errors: list[CalculationError] = []

        # Step 0: PRA Art. 191A(2)(e)(i) two-layer protection look-through.
        # Re-anchors collateral pledged against a guarantee onto the obligor
        # exposure when the bank elects "funded_only" — and suppresses the
        # guarantee row so RWSM substitution does not also apply. Runs first
        # so the rewritten collateral / guarantee frames feed the rest of
        # the CRM chain normally.
        guarantees_lf, collateral_lf, look_through_errors = apply_funded_only_look_through(
            data.guarantees, data.collateral
        )
        errors.extend(look_through_errors)

        # Steps 1-3: provisions -> CCF -> init EAD -> crm_post_ead checkpoint
        exposures = self._run_ead_pipeline(data, config, pack=pack)

        # Generate synthetic collateral from netting (CRR Art. 195)
        exposures, collateral = self._merge_netting_collateral(exposures, collateral_lf, errors)

        # Step 3.7: Split each finite collateral value across its linked
        # beneficiaries (CRR Art. 230-231) when a collateral_links table is
        # supplied. No-op otherwise; the single-beneficiary path is unchanged.
        collateral, link_allocation = self._apply_collateral_links(
            exposures, collateral, data, config, errors
        )

        # Step 3.8: Pre-compute FCSM columns if Simple Method is elected
        # (Art. 222). Must run BEFORE the Comprehensive Method (which is
        # still needed for IRB LGD adjustment).
        use_simple_method = config.crm_collateral_method == CRMCollateralMethod.SIMPLE
        if use_simple_method:
            if has_required_columns(collateral, self.COLLATERAL_REQUIRED_COLUMNS):
                exposures = compute_fcsm_columns(exposures, collateral, config, pack=pack)
            else:
                # Add default (zero) FCSM columns when no valid collateral
                from rwa_calc.engine.crm.simple_method import _add_default_fcsm_columns

                exposures = _add_default_fcsm_columns(exposures)

        # Step 4: Apply collateral (if available and valid)
        exposures, collateral_applied = self._apply_collateral_unified_step(
            exposures, collateral, config, errors, pack=pack
        )

        # Step 4b: Under Simple Method, undo SA financial collateral EAD reduction.
        # The Comprehensive pipeline reduced SA EAD by collateral_adjusted_value,
        # but Art. 222 does not reduce EAD — it substitutes risk weights instead.
        if use_simple_method:
            exposures = undo_sa_ead_reduction(exposures)

        # Pre-compute life insurance method columns (Art. 232) for SA RW mapping
        exposures = self._apply_life_insurance_step(exposures, collateral, config)

        # The second sanctioned INTRA-STAGE checkpoint (with crm_post_ead).
        # Empirically irreducible on Polars 1.37: the guarantee module's
        # 3-path concat (no-guarantee / single / multi-guarantor split)
        # re-evaluates the full collateral plan per branch without it
        # (~4x slowdown at 100K scale), and removing it alone SIGSEGVs on
        # deep plans. Guarded by the plan-node ceiling tests
        # (tests/integration/test_stage_edges.py); re-validate per Polars
        # upgrade before attempting removal.
        if (
            has_required_columns(guarantees_lf, self.GUARANTEE_REQUIRED_COLUMNS)
            and data.counterparty_lookup is not None
        ):
            exposures = materialise_edge(exposures, config, "crm_pre_guarantee_unified")
            exposures = self._apply_guarantees_step(
                exposures, guarantees_lf, data, config, errors, pack=pack
            )
        else:
            self._collect_guarantee_skip_errors(guarantees_lf, data, errors)

        exposures = self._finalize_ead(exposures)
        exposures = self._add_crm_audit(exposures)

        # Stage-exit edge (producer-side): materialise before the audit
        # projections below derive from `exposures`, so they read in-memory
        # data instead of re-executing the guarantee plan.
        exposures = materialise_edge(exposures, config, "crm_exit")

        collateral_allocation = (
            self._build_collateral_allocation(exposures) if collateral_applied else None
        )
        if collateral_allocation is not None:
            sink_audit(collateral_allocation, config, "collateral_allocation")

        # Surface the per-exposure CRM audit projection when the audit cache is
        # opted in. Unified-path bundles normally leave crm_audit=None to avoid
        # a redundant projection on hot runs; sinking only fires when the user
        # has explicitly requested artifacts via config.audit_cache_dir.
        if config.audit_cache_dir is not None:
            sink_audit(self._build_crm_audit(exposures), config, "crm_audit")

        # Producer seal (Phase 3): contract validated, intra-stage scratch
        # stripped — pure plan ops over the eager-backed frame, after the
        # audit projections above have read it. CCR runs carry the SA-CCR
        # provenance columns through, so the contract is selected by the
        # input frame's brand.
        exit_edge = (
            CRM_EXIT_CCR_EDGE
            if sealed_edge_of(data.all_exposures) == "classifier_exit_ccr"
            else CRM_EXIT_EDGE
        )
        exposures = seal(exposures, exit_edge)

        return CRMAdjustedBundle(
            exposures=exposures,
            equity_exposures=data.equity_exposures,
            ciu_holdings=data.ciu_holdings,
            collateral_allocation=collateral_allocation,
            collateral_link_allocation=link_allocation,
            securitisation_audit=data.securitisation_audit,
            crm_errors=errors,
        )

CRR Art. 195 — On-balance sheet netting

generate_netting_collateral — src/rwa_calc/engine/crm/collateral.py:152
@cites("CRR Art. 195")
@cites("CRR Art. 219")
@cites("CRR Art. 223")
def generate_netting_collateral(
    exposures: pl.LazyFrame,
) -> pl.LazyFrame | None:
    """
    Generate synthetic cash collateral from negative-drawn netting-eligible loans.

    When a loan has a negative drawn amount (credit balance / deposit) and carries
    a ``netting_agreement_reference`` (CRR Art. 195/219), the absolute value of
    that negative balance can reduce other exposures covered by the SAME netting
    agreement — treated as synthetic cash collateral.

    Netting is driven SOLELY by ``netting_agreement_reference``: only exposures
    sharing the same reference net together. This reflects the legal right of
    set-off, which is defined by the netting agreement itself — not by facility
    hierarchy or counterparty. A deposit from one counterparty may net a loan to a
    different counterparty (and across different facilities) iff both carry the
    same reference; conversely two exposures in the same facility do NOT net unless
    they share the reference.

    CRR Art. 219 limits on-balance-sheet netting to drawn loans and deposits
    (cash-on-cash). Synthetic cash collateral is allocated pro-rata by the drawn
    portion (`on_bs_for_ead`) to positive-drawn LOAN siblings carrying the same
    reference — contingents and synthetic facility_undrawn rows are
    off-balance-sheet and excluded from the beneficiary set. Netting pools are
    grouped by (netting_agreement_reference, currency) so the haircut pipeline can
    apply FX haircuts when the pool currency differs from the sibling's currency.

    Args:
        exposures: Exposures with ead_for_crm, on_bs_for_ead, exposure_type set

    Returns:
        LazyFrame of synthetic collateral rows, or None if no netting applies
    """
    schema = exposures.collect_schema()
    schema_names = set(schema.names())
    if "netting_agreement_reference" not in schema_names:
        return None

    # Graceful fallback for direct unit-test callers (production always
    # supplies ead_for_crm via _initialize_ead, on_bs_for_ead via _compute_ead,
    # and exposure_type via hierarchy).
    if "ead_for_crm" not in schema_names:
        exposures = exposures.with_columns(pl.col("ead_gross").alias("ead_for_crm"))
    if "on_bs_for_ead" not in schema_names:
        interest_expr = (
            pl.col("interest").fill_null(0.0).clip(lower_bound=0.0)
            if "interest" in schema_names
            else pl.lit(0.0)
        )
        exposures = exposures.with_columns(
            (pl.col("drawn_amount").clip(lower_bound=0.0) + interest_expr).alias("on_bs_for_ead")
        )
    if "exposure_type" not in schema_names:
        exposures = exposures.with_columns(pl.lit("loan").alias("exposure_type"))

    # Negative-drawn loans carrying a netting agreement reference provide the pool
    negative_loans = exposures.filter(
        pl.col("netting_agreement_reference").is_not_null() & (pl.col("drawn_amount") < 0)
    )

    # Sum abs(drawn_amount) per (netting_agreement_reference, currency) → netting pool.
    # Currency is kept so the synthetic collateral carries the source currency,
    # allowing the haircut pipeline to apply FX haircuts when currencies differ.
    netting_pool = (
        negative_loans.group_by(["netting_agreement_reference", "currency"])
        .agg(
            pl.col("drawn_amount").abs().sum().alias("netting_pool"),
        )
        .rename({"currency": "_pool_currency"})
    )

    # CRR Art. 219: drawn-on-drawn cash netting. Synthetic cash collateral may
    # only benefit the drawn portion of loan exposures — contingents and
    # facility_undrawn synthetic rows are off-balance-sheet and ineligible. A
    # sibling matches a pool iff it carries the same netting_agreement_reference.
    positive_siblings = exposures.filter(
        (pl.col("exposure_type") == "loan")
        & (pl.col("on_bs_for_ead") > 0)
        & pl.col("netting_agreement_reference").is_not_null()
    ).select(
        "exposure_reference",
        "netting_agreement_reference",
        "currency",
        "on_bs_for_ead",
        "maturity_date",
    )

    # Match siblings to pools by shared netting agreement reference.
    matched = positive_siblings.join(
        netting_pool,
        on="netting_agreement_reference",
        how="inner",
    )

    # Total drawn EAD per pool for pro-rata allocation. CRR Art. 219 nets cash
    # against drawn loans, so the pro-rata basis is the on-BS (drawn) portion,
    # NOT ead_for_crm (which includes the off-BS nominal at CCF=100% per
    # Art. 223(4) — that override is for collateral valuation, not for OBS
    # netting allocation basis).
    facility_totals = matched.group_by("netting_agreement_reference", "_pool_currency").agg(
        pl.col("on_bs_for_ead").sum().alias("_facility_total_drawn"),
    )

    # Join totals back for pro-rata
    allocated = matched.join(
        facility_totals,
        on=["netting_agreement_reference", "_pool_currency"],
        how="left",
    ).filter(pl.col("_facility_total_drawn") > 0)

    # Pro-rata market_value per sibling by drawn portion (Art. 219).
    allocated = allocated.with_columns(
        (pl.col("netting_pool") * pl.col("on_bs_for_ead") / pl.col("_facility_total_drawn")).alias(
            "market_value"
        ),
    )

    # Build synthetic collateral rows — currency from the pool (source of funds)
    synthetic = allocated.select(
        (pl.lit("NETTING_") + pl.col("exposure_reference")).alias("collateral_reference"),
        pl.lit("cash").alias("collateral_type"),
        pl.col("_pool_currency").alias("currency"),
        pl.col("maturity_date"),
        pl.col("market_value"),
        pl.lit(None).cast(pl.Float64).alias("nominal_value"),
        pl.lit(None).cast(pl.Float64).alias("pledge_percentage"),
        pl.lit("loan").alias("beneficiary_type"),
        pl.col("exposure_reference").alias("beneficiary_reference"),
        pl.lit(None).cast(pl.Int8).alias("issuer_cqs"),
        pl.lit(None).cast(pl.String).alias("issuer_type"),
        pl.lit(None).cast(pl.Float64).alias("residual_maturity_years"),
        pl.lit(True).alias("is_eligible_financial_collateral"),
        pl.lit(True).alias("is_eligible_irb_collateral"),
        pl.lit(None).cast(pl.Date).alias("valuation_date"),
        pl.lit(None).cast(pl.String).alias("valuation_type"),
        pl.lit(None).cast(pl.String).alias("property_type"),
        pl.lit(None).cast(pl.Float64).alias("property_ltv"),
        pl.lit(None).cast(pl.Boolean).alias("is_income_producing"),
        pl.lit(None).cast(pl.Boolean).alias("is_adc"),
        pl.lit(None).cast(pl.Boolean).alias("is_presold"),
    )

    return synthetic

CRR Art. 213 — Requirements common to guarantees and credit derivatives

apply_guarantees — src/rwa_calc/engine/crm/guarantees.py:90
@cites("CRR Art. 213")
@cites("CRR Art. 217")
def apply_guarantees(
    exposures: pl.LazyFrame,
    guarantees: pl.LazyFrame,
    counterparty_lookup: pl.LazyFrame,
    config: CalculationConfig,
    rating_inheritance: pl.LazyFrame | None = None,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """
    Apply guarantee substitution.

    For guaranteed portion, substitute borrower RW with guarantor RW.

    Args:
        exposures: Exposures with EAD
        guarantees: Guarantee data
        counterparty_lookup: For guarantor risk weights
        config: Calculation configuration
        rating_inheritance: For guarantor CQS lookup

    Returns:
        Exposures with guarantee effects applied
    """
    guarantees = _prepare_guarantees(guarantees, exposures, config, pack=pack)

    exposures = exposures.with_columns(
        pl.col("exposure_reference").alias("parent_exposure_reference"),
    )

    exposures = _apply_guarantee_splits(guarantees, exposures)
    exposures = _join_guarantor_counterparty(exposures, counterparty_lookup)
    exposures = _join_guarantor_ratings(exposures, rating_inheritance)

    exposures = exposures.with_columns(
        pl.col("guarantor_entity_type").fill_null("").alias("guarantor_entity_type"),
    )

    # Derive guarantor's exposure class from their entity type. Needed for
    # post-CRM reporting where the guaranteed portion is reported under the
    # guarantor's exposure class.
    exposures = exposures.with_columns(
        pl.col("guarantor_entity_type")
        .replace_strict(ENTITY_TYPE_TO_SA_CLASS, default="")
        .alias("guarantor_exposure_class"),
    )

    exposures = _assign_guarantor_approach(exposures, config)

    # Cross-approach CCF substitution (CRR Art. 111 / COREP C07)
    # When IRB exposure guaranteed by SA counterparty, use SA CCFs for guaranteed portion
    exposures = _apply_cross_approach_ccf(exposures)

    # Add post-CRM composite attributes for regulatory reporting. For the
    # guaranteed portion, the post-CRM counterparty is the guarantor.
    exposures = exposures.with_columns(
        # Post-CRM counterparty for guaranteed portion (guarantor or original)
        pl.when(pl.col("guaranteed_portion") > 0)
        .then(pl.col("guarantor_reference"))
        .otherwise(pl.col("counterparty_reference"))
        .alias("post_crm_counterparty_guaranteed"),
        # Post-CRM exposure class for guaranteed portion (guarantor's class or original)
        pl.when((pl.col("guaranteed_portion") > 0) & (pl.col("guarantor_exposure_class") != ""))
        .then(pl.col("guarantor_exposure_class"))
        .otherwise(pl.col("exposure_class"))
        .alias("post_crm_exposure_class_guaranteed"),
        # Flag indicating whether exposure has an effective guarantee
        (pl.col("guaranteed_portion").fill_null(0.0) > 0).alias("is_guaranteed"),
    )

    # Note: Transient columns (guarantor_entity_type, guarantor_cqs, etc.) are kept
    # because downstream SA/IRB calculators need them for risk weight substitution.
    # They can be dropped in the final output aggregation if needed.

    return exposures
apply_guarantee_substitution — src/rwa_calc/engine/sa/rw_adjustments.py:153
@cites("CRR Art. 213")
def apply_guarantee_substitution(
    lf: pl.LazyFrame,
    config: CalculationConfig,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """Apply guarantee substitution for unfunded credit protection.

    For guaranteed portions, the risk weight is substituted with the
    guarantor's risk weight. The final RWA is calculated using blended
    risk weight based on guaranteed vs unguaranteed portions.

    CRR Art. 213-217: Unfunded credit protection.
    """
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
    exposures = lf
    cols = exposures.collect_schema().names()

    # Run-level sentinel gate: guarantor_entity_type is the one crm_exit
    # column still CONDITIONAL (inject=False) — present iff the CRM
    # guarantee sub-step ran. Keying on it keeps this machinery (and its
    # derived audit columns: pre_crm_risk_weight, guarantor_rw,
    # is_guarantee_beneficial, guarantee_status, guarantee_benefit_rw)
    # off unguaranteed runs; see contracts/edges.py. The
    # guaranteed_portion check covers direct (non-pipeline) invocation.
    if "guaranteed_portion" not in cols or "guarantor_entity_type" not in cols:
        return exposures

    # Ensure defensive column fallbacks (guarantor_exposure_class,
    # guarantor_country_code, guarantor_is_ccp_client_cleared). In
    # production these are set by the CRM processor; this fallback covers
    # tests that construct LazyFrames directly and skip the CRM stage.
    exposures = _ensure_guarantee_substitution_columns(exposures)

    # Preserve pre-CRM risk weight for regulatory reporting (pre-CRM vs
    # post-CRM views).
    exposures = exposures.with_columns(
        pl.col("risk_weight").alias("pre_crm_risk_weight"),
    )

    # Art. 114(4)/(7) domestic CGCB-guarantor currency check.
    is_domestic_guarantor = _build_domestic_guarantor_expr(exposures.collect_schema().names())

    # CRR/PS1/26 Art. 120(2) Table 4 short-term institution guarantor flag.
    # The substituted exposure's original maturity (≤ 3 months / 0.25y)
    # drives the short-term carve-out — same convention as the direct
    # institution short-term branches in ``risk_weights.py`` (Art. 120(2),
    # Art. 121(3)). ``original_maturity_years`` is derived earlier in
    # ``apply_risk_weights`` from (maturity_date - value_date) when absent,
    # so it is always populated here.
    short_term_flag_col = "_inst_guarantor_short_term"
    if "original_maturity_years" in exposures.collect_schema().names():
        short_term_expr = pl.col("original_maturity_years").is_not_null() & (
            pl.col("original_maturity_years") <= 0.25
        )
    else:
        short_term_expr = pl.lit(False)
    exposures = exposures.with_columns(
        short_term_expr.fill_null(False).alias(short_term_flag_col),
    )

    # Look up guarantor's RW based on exposure class + CQS. The short-term
    # flag is calculator scratch consumed only by this expression — drop it
    # immediately so it never leaks into the branch/aggregator frames.
    exposures = exposures.with_columns(
        _build_guarantor_rw_expr(
            is_domestic_guarantor,
            resolved_pack.feature("sa_revised_risk_weight_tables"),
            institution_short_term_flag_col=short_term_flag_col,
        ).alias("guarantor_rw"),
    ).drop(short_term_flag_col)

    # Check if guarantee is beneficial (guarantor RW < borrower RW)
    # Non-beneficial guarantees should NOT be applied per CRR Art. 213
    exposures = exposures.with_columns(
        [
            pl.when(
                (pl.col("guaranteed_portion") > 0)
                & (pl.col("guarantor_rw").is_not_null())
                & (pl.col("guarantor_rw") < pl.col("pre_crm_risk_weight"))
            )
            .then(pl.lit(True))
            .otherwise(pl.lit(False))
            .alias("is_guarantee_beneficial"),
        ]
    )

    # Redistribute non-beneficial guarantee portions to beneficial guarantors.
    # For multi-guarantor exposures, non-beneficial guarantors' EAD is reallocated
    # to the most beneficial (lowest RW) guarantors using greedy fill.
    from rwa_calc.engine.crm.guarantees import redistribute_non_beneficial

    exposures = redistribute_non_beneficial(exposures)

    # Calculate blended risk weight using substitution approach
    # Only apply if guarantee is beneficial
    # RWA = (unguaranteed_portion * borrower_rw + guaranteed_portion * guarantor_rw) / ead_final
    exposures = exposures.with_columns(
        [
            # Blended risk weight when guarantee exists AND is beneficial
            pl.when(
                (pl.col("guaranteed_portion") > 0)
                & (pl.col("guarantor_rw").is_not_null())
                & (pl.col("is_guarantee_beneficial"))
            )
            .then(
                # weighted average of borrower and guarantor risk weights
                (
                    pl.col("unguaranteed_portion") * pl.col("pre_crm_risk_weight")
                    + pl.col("guaranteed_portion") * pl.col("guarantor_rw")
                )
                / pl.col("ead_final")
            )
            # No guarantee, no guarantor RW, or non-beneficial - use original risk weight
            .otherwise(pl.col("pre_crm_risk_weight"))
            .alias("risk_weight"),
        ]
    )

    # Track guarantee status for reporting
    exposures = exposures.with_columns(
        [
            pl.when(pl.col("guaranteed_portion") <= 0)
            .then(pl.lit("NO_GUARANTEE"))
            .when(~pl.col("is_guarantee_beneficial"))
            .then(pl.lit("GUARANTEE_NOT_APPLIED_NON_BENEFICIAL"))
            .otherwise(pl.lit("SA_RW_SUBSTITUTION"))
            .alias("guarantee_status"),
            # Calculate RW benefit from guarantee (positive = RW reduced)
            pl.when(pl.col("is_guarantee_beneficial"))
            .then(pl.col("pre_crm_risk_weight") - pl.col("risk_weight"))
            .otherwise(pl.lit(0.0))
            .alias("guarantee_benefit_rw"),
        ]
    )

    return exposures

CRR Art. 217 — Requirements to qualify for the treatment set out in Article 153(3)

apply_guarantees — src/rwa_calc/engine/crm/guarantees.py:91
@cites("CRR Art. 213")
@cites("CRR Art. 217")
def apply_guarantees(
    exposures: pl.LazyFrame,
    guarantees: pl.LazyFrame,
    counterparty_lookup: pl.LazyFrame,
    config: CalculationConfig,
    rating_inheritance: pl.LazyFrame | None = None,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """
    Apply guarantee substitution.

    For guaranteed portion, substitute borrower RW with guarantor RW.

    Args:
        exposures: Exposures with EAD
        guarantees: Guarantee data
        counterparty_lookup: For guarantor risk weights
        config: Calculation configuration
        rating_inheritance: For guarantor CQS lookup

    Returns:
        Exposures with guarantee effects applied
    """
    guarantees = _prepare_guarantees(guarantees, exposures, config, pack=pack)

    exposures = exposures.with_columns(
        pl.col("exposure_reference").alias("parent_exposure_reference"),
    )

    exposures = _apply_guarantee_splits(guarantees, exposures)
    exposures = _join_guarantor_counterparty(exposures, counterparty_lookup)
    exposures = _join_guarantor_ratings(exposures, rating_inheritance)

    exposures = exposures.with_columns(
        pl.col("guarantor_entity_type").fill_null("").alias("guarantor_entity_type"),
    )

    # Derive guarantor's exposure class from their entity type. Needed for
    # post-CRM reporting where the guaranteed portion is reported under the
    # guarantor's exposure class.
    exposures = exposures.with_columns(
        pl.col("guarantor_entity_type")
        .replace_strict(ENTITY_TYPE_TO_SA_CLASS, default="")
        .alias("guarantor_exposure_class"),
    )

    exposures = _assign_guarantor_approach(exposures, config)

    # Cross-approach CCF substitution (CRR Art. 111 / COREP C07)
    # When IRB exposure guaranteed by SA counterparty, use SA CCFs for guaranteed portion
    exposures = _apply_cross_approach_ccf(exposures)

    # Add post-CRM composite attributes for regulatory reporting. For the
    # guaranteed portion, the post-CRM counterparty is the guarantor.
    exposures = exposures.with_columns(
        # Post-CRM counterparty for guaranteed portion (guarantor or original)
        pl.when(pl.col("guaranteed_portion") > 0)
        .then(pl.col("guarantor_reference"))
        .otherwise(pl.col("counterparty_reference"))
        .alias("post_crm_counterparty_guaranteed"),
        # Post-CRM exposure class for guaranteed portion (guarantor's class or original)
        pl.when((pl.col("guaranteed_portion") > 0) & (pl.col("guarantor_exposure_class") != ""))
        .then(pl.col("guarantor_exposure_class"))
        .otherwise(pl.col("exposure_class"))
        .alias("post_crm_exposure_class_guaranteed"),
        # Flag indicating whether exposure has an effective guarantee
        (pl.col("guaranteed_portion").fill_null(0.0) > 0).alias("is_guaranteed"),
    )

    # Note: Transient columns (guarantor_entity_type, guarantor_cqs, etc.) are kept
    # because downstream SA/IRB calculators need them for risk weight substitution.
    # They can be dropped in the final output aggregation if needed.

    return exposures
_apply_maturity_mismatch_to_guarantees — src/rwa_calc/engine/crm/guarantees.py:1267
@cites("CRR Art. 217")
def _apply_maturity_mismatch_to_guarantees(
    guarantees: pl.LazyFrame,
    exposures: pl.LazyFrame,
    config: CalculationConfig,
) -> pl.LazyFrame:
    """
    Apply CRR Art. 239(3) maturity mismatch scaling to guarantee amounts.

    When the protection's residual maturity ``t`` is shorter than the
    exposure's effective maturity ``T``, the covered amount ``G`` is scaled:

        GA = G* × (t - 0.25) / (T - 0.25)

    with ``T`` capped at 5.0 years and both ``t`` and ``T`` floored at 0.25
    (so any t < 0.25 yields zero coverage; the >=1y original-maturity floor
    in Art. 237(2)(a) is enforced separately upstream). Scaling is applied
    to ``amount_covered`` and ``percentage_covered`` before the split, so
    the reduced nominal protection value propagates through cap-at-EAD.

    The protection residual maturity ``t`` is derived from the guarantee
    row's ``maturity_date`` if present, otherwise from
    ``original_maturity_years``. The exposure residual ``T`` is derived
    from the exposure's ``maturity_date``.

    References:
        CRR Art. 237(2): minimum maturity / mismatch eligibility
        CRR Art. 238(1): maturity of credit protection
        CRR Art. 239(3): maturity mismatch adjustment formula
    """
    guar_schema = guarantees.collect_schema()
    guar_cols = guar_schema.names()
    exp_schema = exposures.collect_schema()
    exp_cols = exp_schema.names()

    # Need exposure maturity_date and at least one of guarantee maturity_date
    # / original_maturity_years to compute t and T.
    if "maturity_date" not in exp_cols:
        return guarantees
    has_guar_maturity_date = "maturity_date" in guar_cols
    has_guar_original_maturity = "original_maturity_years" in guar_cols
    if not (has_guar_maturity_date or has_guar_original_maturity):
        return guarantees

    # Bring exposure residual maturity (years) onto each guarantee row.
    exp_t_expr = exact_fractional_years_expr(config.reporting_date, "maturity_date").alias("_exp_T")
    exp_lookup = exposures.select(
        pl.col("exposure_reference"),
        exp_t_expr,
    )

    guarantees = guarantees.join(
        exp_lookup,
        left_on="beneficiary_reference",
        right_on="exposure_reference",
        how="left",
    )

    # Compute t (Art. 238(1)). Prefer the explicit regulatory input
    # ``original_maturity_years`` when present (it is the authoritative
    # contract term written by upstream loaders); fall back to
    # ``maturity_date`` minus reporting date when missing. Null t means
    # "no info" and yields no scaling.
    if has_guar_original_maturity and has_guar_maturity_date:
        t_from_date = exact_fractional_years_expr(config.reporting_date, "maturity_date")
        t_raw = (
            pl.when(pl.col("original_maturity_years").is_not_null())
            .then(pl.col("original_maturity_years"))
            .otherwise(t_from_date)
        )
    elif has_guar_original_maturity:
        t_raw = pl.col("original_maturity_years")
    else:
        t_raw = exact_fractional_years_expr(config.reporting_date, "maturity_date")

    # Apply Art. 239(3) floors / caps:
    #   t floored at 0.25, T capped at 5.0 and floored at 0.25.
    floor = pl.lit(0.25)
    cap = pl.lit(5.0)
    t_eff = pl.max_horizontal(t_raw, floor)
    t_eff_safe = pl.when(t_raw.is_null()).then(pl.lit(None, dtype=pl.Float64)).otherwise(t_eff)
    exp_t_eff = pl.max_horizontal(pl.min_horizontal(pl.col("_exp_T"), cap), floor)
    exp_t_eff_safe = (
        pl.when(pl.col("_exp_T").is_null())
        .then(pl.lit(None, dtype=pl.Float64))
        .otherwise(exp_t_eff)
    )

    # Mismatch only applies when t < T (else no scaling).
    is_mismatch = (
        t_eff_safe.is_not_null() & exp_t_eff_safe.is_not_null() & (t_eff_safe < exp_t_eff_safe)
    )
    scale = (t_eff_safe - floor) / (exp_t_eff_safe - floor)
    scale_safe = pl.when(is_mismatch).then(scale).otherwise(pl.lit(1.0))

    scale_exprs: list[pl.Expr] = []
    if "amount_covered" in guar_cols:
        scale_exprs.append((pl.col("amount_covered") * scale_safe).alias("amount_covered"))
    if "percentage_covered" in guar_cols:
        scale_exprs.append((pl.col("percentage_covered") * scale_safe).alias("percentage_covered"))

    if scale_exprs:
        guarantees = guarantees.with_columns(scale_exprs)

    return guarantees.drop("_exp_T")

CRR Art. 219 — On-balance sheet netting

generate_netting_collateral — src/rwa_calc/engine/crm/collateral.py:153
@cites("CRR Art. 195")
@cites("CRR Art. 219")
@cites("CRR Art. 223")
def generate_netting_collateral(
    exposures: pl.LazyFrame,
) -> pl.LazyFrame | None:
    """
    Generate synthetic cash collateral from negative-drawn netting-eligible loans.

    When a loan has a negative drawn amount (credit balance / deposit) and carries
    a ``netting_agreement_reference`` (CRR Art. 195/219), the absolute value of
    that negative balance can reduce other exposures covered by the SAME netting
    agreement — treated as synthetic cash collateral.

    Netting is driven SOLELY by ``netting_agreement_reference``: only exposures
    sharing the same reference net together. This reflects the legal right of
    set-off, which is defined by the netting agreement itself — not by facility
    hierarchy or counterparty. A deposit from one counterparty may net a loan to a
    different counterparty (and across different facilities) iff both carry the
    same reference; conversely two exposures in the same facility do NOT net unless
    they share the reference.

    CRR Art. 219 limits on-balance-sheet netting to drawn loans and deposits
    (cash-on-cash). Synthetic cash collateral is allocated pro-rata by the drawn
    portion (`on_bs_for_ead`) to positive-drawn LOAN siblings carrying the same
    reference — contingents and synthetic facility_undrawn rows are
    off-balance-sheet and excluded from the beneficiary set. Netting pools are
    grouped by (netting_agreement_reference, currency) so the haircut pipeline can
    apply FX haircuts when the pool currency differs from the sibling's currency.

    Args:
        exposures: Exposures with ead_for_crm, on_bs_for_ead, exposure_type set

    Returns:
        LazyFrame of synthetic collateral rows, or None if no netting applies
    """
    schema = exposures.collect_schema()
    schema_names = set(schema.names())
    if "netting_agreement_reference" not in schema_names:
        return None

    # Graceful fallback for direct unit-test callers (production always
    # supplies ead_for_crm via _initialize_ead, on_bs_for_ead via _compute_ead,
    # and exposure_type via hierarchy).
    if "ead_for_crm" not in schema_names:
        exposures = exposures.with_columns(pl.col("ead_gross").alias("ead_for_crm"))
    if "on_bs_for_ead" not in schema_names:
        interest_expr = (
            pl.col("interest").fill_null(0.0).clip(lower_bound=0.0)
            if "interest" in schema_names
            else pl.lit(0.0)
        )
        exposures = exposures.with_columns(
            (pl.col("drawn_amount").clip(lower_bound=0.0) + interest_expr).alias("on_bs_for_ead")
        )
    if "exposure_type" not in schema_names:
        exposures = exposures.with_columns(pl.lit("loan").alias("exposure_type"))

    # Negative-drawn loans carrying a netting agreement reference provide the pool
    negative_loans = exposures.filter(
        pl.col("netting_agreement_reference").is_not_null() & (pl.col("drawn_amount") < 0)
    )

    # Sum abs(drawn_amount) per (netting_agreement_reference, currency) → netting pool.
    # Currency is kept so the synthetic collateral carries the source currency,
    # allowing the haircut pipeline to apply FX haircuts when currencies differ.
    netting_pool = (
        negative_loans.group_by(["netting_agreement_reference", "currency"])
        .agg(
            pl.col("drawn_amount").abs().sum().alias("netting_pool"),
        )
        .rename({"currency": "_pool_currency"})
    )

    # CRR Art. 219: drawn-on-drawn cash netting. Synthetic cash collateral may
    # only benefit the drawn portion of loan exposures — contingents and
    # facility_undrawn synthetic rows are off-balance-sheet and ineligible. A
    # sibling matches a pool iff it carries the same netting_agreement_reference.
    positive_siblings = exposures.filter(
        (pl.col("exposure_type") == "loan")
        & (pl.col("on_bs_for_ead") > 0)
        & pl.col("netting_agreement_reference").is_not_null()
    ).select(
        "exposure_reference",
        "netting_agreement_reference",
        "currency",
        "on_bs_for_ead",
        "maturity_date",
    )

    # Match siblings to pools by shared netting agreement reference.
    matched = positive_siblings.join(
        netting_pool,
        on="netting_agreement_reference",
        how="inner",
    )

    # Total drawn EAD per pool for pro-rata allocation. CRR Art. 219 nets cash
    # against drawn loans, so the pro-rata basis is the on-BS (drawn) portion,
    # NOT ead_for_crm (which includes the off-BS nominal at CCF=100% per
    # Art. 223(4) — that override is for collateral valuation, not for OBS
    # netting allocation basis).
    facility_totals = matched.group_by("netting_agreement_reference", "_pool_currency").agg(
        pl.col("on_bs_for_ead").sum().alias("_facility_total_drawn"),
    )

    # Join totals back for pro-rata
    allocated = matched.join(
        facility_totals,
        on=["netting_agreement_reference", "_pool_currency"],
        how="left",
    ).filter(pl.col("_facility_total_drawn") > 0)

    # Pro-rata market_value per sibling by drawn portion (Art. 219).
    allocated = allocated.with_columns(
        (pl.col("netting_pool") * pl.col("on_bs_for_ead") / pl.col("_facility_total_drawn")).alias(
            "market_value"
        ),
    )

    # Build synthetic collateral rows — currency from the pool (source of funds)
    synthetic = allocated.select(
        (pl.lit("NETTING_") + pl.col("exposure_reference")).alias("collateral_reference"),
        pl.lit("cash").alias("collateral_type"),
        pl.col("_pool_currency").alias("currency"),
        pl.col("maturity_date"),
        pl.col("market_value"),
        pl.lit(None).cast(pl.Float64).alias("nominal_value"),
        pl.lit(None).cast(pl.Float64).alias("pledge_percentage"),
        pl.lit("loan").alias("beneficiary_type"),
        pl.col("exposure_reference").alias("beneficiary_reference"),
        pl.lit(None).cast(pl.Int8).alias("issuer_cqs"),
        pl.lit(None).cast(pl.String).alias("issuer_type"),
        pl.lit(None).cast(pl.Float64).alias("residual_maturity_years"),
        pl.lit(True).alias("is_eligible_financial_collateral"),
        pl.lit(True).alias("is_eligible_irb_collateral"),
        pl.lit(None).cast(pl.Date).alias("valuation_date"),
        pl.lit(None).cast(pl.String).alias("valuation_type"),
        pl.lit(None).cast(pl.String).alias("property_type"),
        pl.lit(None).cast(pl.Float64).alias("property_ltv"),
        pl.lit(None).cast(pl.Boolean).alias("is_income_producing"),
        pl.lit(None).cast(pl.Boolean).alias("is_adc"),
        pl.lit(None).cast(pl.Boolean).alias("is_presold"),
    )

    return synthetic

CRR Art. 220 — Using the Supervisory Volatility Adjustments Approach or the Own Estimates Volatility Adjustments Approach for master netting agreements

sft_bundle_to_exposures — src/rwa_calc/engine/sft/fccm.py:109
@cites("CRR Art. 220")
@cites("CRR Art. 223")
@cites("CRR Art. 224")
@cites("CRR Art. 226")
@cites("CRR Art. 271")
@cites("CRR Art. 285")
def sft_bundle_to_exposures(
    raw_sft: RawSFTBundle,
    reporting_date: date,
    rulepack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """Shape FCCM SFT EADs into synthetic exposure rows from the lean SFT bundle.

    The sole FCCM entry point (SFT/FCCM separation): consumes the dedicated
    :class:`RawSFTBundle` (``RawDataBundle.sft``). The SFT/derivative
    discrimination lives in the *input bundle* now, not in any in-engine
    ``transaction_type`` split:

    - Every trade row is an SFT (no ``transaction_type`` filter): the whole
      ``raw_sft.trades`` frame is in scope.
    - The netting-set ``counterparty_reference`` is denormalised onto the trade
      row (FCCM scope is single-trade single-counterparty netting sets,
      Art. 220(1)(a)), so the NS-grain counterparty frame is derived from the
      trades themselves rather than a separate netting-set table.
    - Collateral is OPTIONAL (``raw_sft.collateral is None`` for an
      uncollateralised SFT, the common case): a missing collateral leaf yields a
      zero collateral term (CVA·(1−HC−HFX) = 0), exactly as an empty
      ``ccr_collateral`` frame would.

    Each emitted synthetic exposure row carries the FCCM provenance:
    ``exposure_reference = "ccr__<netting_set_id>"``, ``risk_type = "CCR_SFT"``,
    ``ccr_method = "fccm_sft"``, ``drawn_amount = E*``, ``ead_ccr = E*``.

    Args:
        raw_sft: The SFT (FCCM) input bundle — every trade row is an SFT with the
            denormalised netting-set counterparty; collateral optional.
        reporting_date: As-of date; written to ``value_date``.
        rulepack: The resolved RUN rulepack supplying the Art. 162 effective-
            maturity floors / regime gate for the ``ccr_effective_maturity``
            carrier. ``None`` (the back-compat default used by direct unit /
            acceptance calls) falls back to the module-level CRR ``_PACK``; the
            stage adapter threads the run pack so production runs are regime-
            correct.

    Returns:
        LazyFrame at netting-set grain. Empty (zero-row) frame when the trades
        bundle is empty.

    References:
        CRR Art. 271(2); Art. 220(1)(a); Art. 223(5); Art. 224 Table 1;
        Art. 224(2)(b); Art. 226; Art. 285(2)-(5).
    """
    sft_trades_lf = raw_sft.trades.sft_trades
    # Counterparty is denormalised onto the trade — collapse to NS grain. The
    # ``first()`` aggregation is exact under the single-CP-per-NS scope
    # (Art. 220(1)(a)); should a future netting set span counterparties the
    # FCCM scope itself would need revisiting.
    ns_counterparty_lf = sft_trades_lf.group_by("netting_set_id").agg(
        pl.col("counterparty_reference").first()
    )
    ccr_collateral_lf = (
        raw_sft.collateral.sft_collateral if raw_sft.collateral is not None else None
    )
    return _build_sft_exposure_rows(
        sft_trades_lf=sft_trades_lf,
        ns_counterparty_lf=ns_counterparty_lf,
        ccr_collateral_lf=ccr_collateral_lf,
        reporting_date=reporting_date,
        pack=rulepack if rulepack is not None else _PACK,
    )

CRR Art. 222 — Financial Collateral Simple Method

compute_fcsm_columns — src/rwa_calc/engine/crm/simple_method.py:271
@cites("CRR Art. 222")
def compute_fcsm_columns(
    exposures: pl.LazyFrame,
    collateral: pl.LazyFrame | None,
    config: CalculationConfig,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """Compute FCSM columns on the exposure frame.

    Aggregates eligible financial collateral per exposure and sets:
    - fcsm_collateral_value: total raw market value of eligible financial collateral
      allocated to this exposure (capped at EAD)
    - fcsm_collateral_rw: weighted-average SA risk weight of the collateral

    Does NOT modify any EAD columns. The SA calculator uses these columns
    for risk weight substitution via _apply_fcsm_rw_substitution().

    IRB exposures are unaffected — Simple Method is SA-only per Art. 222.

    Args:
        exposures: Exposure frame with ead_gross, exposure_reference, etc.
        collateral: Collateral frame (may be None if no collateral).
        config: Calculation configuration.
        pack: Resolved rulepack supplying the Art. 222 floor scalars. Production
            passes the run's pack; direct callers default to ``None``, which
            resolves a pack from ``config`` (same regime/date).

    Returns:
        Exposure frame with fcsm_collateral_value and fcsm_collateral_rw columns.
    """
    if collateral is None:
        return _add_default_fcsm_columns(exposures)

    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
    floors = _FcsmFloors.from_pack(resolved_pack)

    schema = exposures.collect_schema()
    schema_names = schema.names()
    ead_col = "ead_gross" if "ead_gross" in schema_names else "ead"
    exp_ref_col = "exposure_reference" if "exposure_reference" in schema_names else "loan_reference"
    facility_col = "parent_facility_reference"
    cp_col = "counterparty_reference"

    schema_flags = _SchemaFlags(
        has_exp_maturity="residual_maturity_years" in schema_names,
        sft_col=_resolve_sft_column(schema_names),
        has_cmp_col="cp_is_core_market_participant" in schema_names,
        has_currency="currency" in schema_names,
        has_facility=facility_col in schema_names,
        has_counterparty=cp_col in schema_names,
    )

    # 1. Filter to eligible financial collateral + ensure zero-haircut flag
    # 2. Derive per-item RW. The institution (Art. 120 ECRA/SCRA) and corporate
    #    (Art. 122 Table 6) SA RW tables selected here are the SAME regime concept
    #    the SA calculator gates — reuse the cited `sa_revised_risk_weight_tables`
    #    Feature (S9b dedupe) rather than reading config.is_basel_3_1. The helper
    #    keeps its `is_b31` bool plumbing param (Option B).
    eligible = _prepare_eligible_collateral(
        collateral,
        resolved_pack.feature("sa_revised_risk_weight_tables"),
        floors.equity_collateral_rw,
    )

    # 3. Multi-level join (direct + facility + counterparty) to bring exposure
    # currency, maturity, SFT and CMP flags onto each collateral row.
    coll_with_exp = _join_exposure_levels(
        eligible, exposures, exp_ref_col, facility_col, cp_col, ead_col, schema_flags
    )

    # 4. Resolve coalesced exposure-level columns and Art. 222(4) gating flags.
    coll_with_exp = _resolve_exposure_levels(coll_with_exp)

    # 5. Same-currency check + Art. 222(6)(b) sovereign-bond discount.
    coll_with_exp = _apply_currency_and_sovereign_discount(coll_with_exp, floors)

    # 6. Per-item secured-portion RW (Art. 222(3)/(4)/(6) decision tree).
    coll_with_exp = coll_with_exp.with_columns(
        _secured_floor_expr(floors).alias("_fcsm_effective_rw"),
    )

    # 6b. Art. 239(1) FCSM maturity-mismatch eligibility gate.
    coll_with_exp = _apply_maturity_eligibility_gate(coll_with_exp)

    # 7. Aggregate per beneficiary_reference.
    agg = _aggregate_per_beneficiary(coll_with_exp)

    # 8-9. Multi-level join back to exposures and combine with pro-rata shares.
    result = _join_aggregates_back(
        exposures, agg, exp_ref_col, facility_col, cp_col, ead_col, schema_flags
    )

    # 10. Cap collateral value at EAD; RW floor was applied per-item in step 6.
    result = _finalise_fcsm_columns(result, ead_col)

    # Drop temporary columns
    temp_cols = [
        c
        for c in result.collect_schema().names()
        if c.startswith("_fcsm_") or c in ("_fac_ead_total", "_cp_ead_total")
    ]
    return result.drop(temp_cols)
apply_fcsm_rw_substitution — src/rwa_calc/engine/sa/rw_adjustments.py:67
@cites("CRR Art. 222")
def apply_fcsm_rw_substitution(lf: pl.LazyFrame, config: CalculationConfig) -> pl.LazyFrame:
    """Apply Art. 222 Financial Collateral Simple Method risk weight substitution.

    When the Simple Method is elected, the secured portion of each SA exposure
    gets the collateral's SA risk weight instead of the exposure's own risk
    weight. The unsecured portion retains the original RW.

    Blended RW = secured_pct × collateral_rw + unsecured_pct × exposure_rw

    The 20% floor (Art. 222(1)/(3)) and same-currency 0% carve-outs (CRR
    Art. 222(4) / PRA PS1/26 Art. 222(6)) are applied per item in
    ``compute_fcsm_columns``. Applying the floor again on the aggregate would
    re-impose it on carve-out items — contrary to "except as specified in
    paragraphs 4 to 6".

    This function is a no-op when the Comprehensive Method is elected (default)
    or when fcsm_collateral_value is zero/null — the crm_exit contract
    injects both fcsm_* columns as typed nulls when the FCSM sub-step did
    not run, and ``fill_null(0.0)`` makes an all-null column equivalent to
    the historical absent-column early return.
    """
    if config.crm_collateral_method != CRMCollateralMethod.SIMPLE:
        return lf

    ead = pl.col("ead_final").fill_null(0.0)
    fcsm_value = pl.col("fcsm_collateral_value").fill_null(0.0)
    fcsm_rw = pl.col("fcsm_collateral_rw").fill_null(0.0)

    # Secured percentage (capped at 100%)
    secured_pct = pl.when(ead > 0).then((fcsm_value / ead).clip(0.0, 1.0)).otherwise(0.0)
    unsecured_pct = pl.lit(1.0) - secured_pct

    # Blended risk weight; secured RW already reflects per-item floor + carve-outs.
    blended_rw = secured_pct * fcsm_rw + unsecured_pct * pl.col("risk_weight")

    # Only apply when there is actual collateral value
    has_fcsm = fcsm_value > 0

    return lf.with_columns(
        # Save pre-FCSM risk weight for audit
        pl.col("risk_weight").alias("pre_fcsm_risk_weight"),
        # Apply blended RW
        pl.when(has_fcsm).then(blended_rw).otherwise(pl.col("risk_weight")).alias("risk_weight"),
        # Track method for audit/COREP
        pl.when(has_fcsm)
        .then(pl.lit("simple"))
        .otherwise(pl.lit("comprehensive"))
        .alias("ead_calculation_method"),
    )

CRR Art. 223 — Financial Collateral Comprehensive Method

generate_netting_collateral — src/rwa_calc/engine/crm/collateral.py:154
@cites("CRR Art. 195")
@cites("CRR Art. 219")
@cites("CRR Art. 223")
def generate_netting_collateral(
    exposures: pl.LazyFrame,
) -> pl.LazyFrame | None:
    """
    Generate synthetic cash collateral from negative-drawn netting-eligible loans.

    When a loan has a negative drawn amount (credit balance / deposit) and carries
    a ``netting_agreement_reference`` (CRR Art. 195/219), the absolute value of
    that negative balance can reduce other exposures covered by the SAME netting
    agreement — treated as synthetic cash collateral.

    Netting is driven SOLELY by ``netting_agreement_reference``: only exposures
    sharing the same reference net together. This reflects the legal right of
    set-off, which is defined by the netting agreement itself — not by facility
    hierarchy or counterparty. A deposit from one counterparty may net a loan to a
    different counterparty (and across different facilities) iff both carry the
    same reference; conversely two exposures in the same facility do NOT net unless
    they share the reference.

    CRR Art. 219 limits on-balance-sheet netting to drawn loans and deposits
    (cash-on-cash). Synthetic cash collateral is allocated pro-rata by the drawn
    portion (`on_bs_for_ead`) to positive-drawn LOAN siblings carrying the same
    reference — contingents and synthetic facility_undrawn rows are
    off-balance-sheet and excluded from the beneficiary set. Netting pools are
    grouped by (netting_agreement_reference, currency) so the haircut pipeline can
    apply FX haircuts when the pool currency differs from the sibling's currency.

    Args:
        exposures: Exposures with ead_for_crm, on_bs_for_ead, exposure_type set

    Returns:
        LazyFrame of synthetic collateral rows, or None if no netting applies
    """
    schema = exposures.collect_schema()
    schema_names = set(schema.names())
    if "netting_agreement_reference" not in schema_names:
        return None

    # Graceful fallback for direct unit-test callers (production always
    # supplies ead_for_crm via _initialize_ead, on_bs_for_ead via _compute_ead,
    # and exposure_type via hierarchy).
    if "ead_for_crm" not in schema_names:
        exposures = exposures.with_columns(pl.col("ead_gross").alias("ead_for_crm"))
    if "on_bs_for_ead" not in schema_names:
        interest_expr = (
            pl.col("interest").fill_null(0.0).clip(lower_bound=0.0)
            if "interest" in schema_names
            else pl.lit(0.0)
        )
        exposures = exposures.with_columns(
            (pl.col("drawn_amount").clip(lower_bound=0.0) + interest_expr).alias("on_bs_for_ead")
        )
    if "exposure_type" not in schema_names:
        exposures = exposures.with_columns(pl.lit("loan").alias("exposure_type"))

    # Negative-drawn loans carrying a netting agreement reference provide the pool
    negative_loans = exposures.filter(
        pl.col("netting_agreement_reference").is_not_null() & (pl.col("drawn_amount") < 0)
    )

    # Sum abs(drawn_amount) per (netting_agreement_reference, currency) → netting pool.
    # Currency is kept so the synthetic collateral carries the source currency,
    # allowing the haircut pipeline to apply FX haircuts when currencies differ.
    netting_pool = (
        negative_loans.group_by(["netting_agreement_reference", "currency"])
        .agg(
            pl.col("drawn_amount").abs().sum().alias("netting_pool"),
        )
        .rename({"currency": "_pool_currency"})
    )

    # CRR Art. 219: drawn-on-drawn cash netting. Synthetic cash collateral may
    # only benefit the drawn portion of loan exposures — contingents and
    # facility_undrawn synthetic rows are off-balance-sheet and ineligible. A
    # sibling matches a pool iff it carries the same netting_agreement_reference.
    positive_siblings = exposures.filter(
        (pl.col("exposure_type") == "loan")
        & (pl.col("on_bs_for_ead") > 0)
        & pl.col("netting_agreement_reference").is_not_null()
    ).select(
        "exposure_reference",
        "netting_agreement_reference",
        "currency",
        "on_bs_for_ead",
        "maturity_date",
    )

    # Match siblings to pools by shared netting agreement reference.
    matched = positive_siblings.join(
        netting_pool,
        on="netting_agreement_reference",
        how="inner",
    )

    # Total drawn EAD per pool for pro-rata allocation. CRR Art. 219 nets cash
    # against drawn loans, so the pro-rata basis is the on-BS (drawn) portion,
    # NOT ead_for_crm (which includes the off-BS nominal at CCF=100% per
    # Art. 223(4) — that override is for collateral valuation, not for OBS
    # netting allocation basis).
    facility_totals = matched.group_by("netting_agreement_reference", "_pool_currency").agg(
        pl.col("on_bs_for_ead").sum().alias("_facility_total_drawn"),
    )

    # Join totals back for pro-rata
    allocated = matched.join(
        facility_totals,
        on=["netting_agreement_reference", "_pool_currency"],
        how="left",
    ).filter(pl.col("_facility_total_drawn") > 0)

    # Pro-rata market_value per sibling by drawn portion (Art. 219).
    allocated = allocated.with_columns(
        (pl.col("netting_pool") * pl.col("on_bs_for_ead") / pl.col("_facility_total_drawn")).alias(
            "market_value"
        ),
    )

    # Build synthetic collateral rows — currency from the pool (source of funds)
    synthetic = allocated.select(
        (pl.lit("NETTING_") + pl.col("exposure_reference")).alias("collateral_reference"),
        pl.lit("cash").alias("collateral_type"),
        pl.col("_pool_currency").alias("currency"),
        pl.col("maturity_date"),
        pl.col("market_value"),
        pl.lit(None).cast(pl.Float64).alias("nominal_value"),
        pl.lit(None).cast(pl.Float64).alias("pledge_percentage"),
        pl.lit("loan").alias("beneficiary_type"),
        pl.col("exposure_reference").alias("beneficiary_reference"),
        pl.lit(None).cast(pl.Int8).alias("issuer_cqs"),
        pl.lit(None).cast(pl.String).alias("issuer_type"),
        pl.lit(None).cast(pl.Float64).alias("residual_maturity_years"),
        pl.lit(True).alias("is_eligible_financial_collateral"),
        pl.lit(True).alias("is_eligible_irb_collateral"),
        pl.lit(None).cast(pl.Date).alias("valuation_date"),
        pl.lit(None).cast(pl.String).alias("valuation_type"),
        pl.lit(None).cast(pl.String).alias("property_type"),
        pl.lit(None).cast(pl.Float64).alias("property_ltv"),
        pl.lit(None).cast(pl.Boolean).alias("is_income_producing"),
        pl.lit(None).cast(pl.Boolean).alias("is_adc"),
        pl.lit(None).cast(pl.Boolean).alias("is_presold"),
    )

    return synthetic
apply_collateral — src/rwa_calc/engine/crm/collateral.py:302
@cites("PS1/26 Art. 230(2)")
@cites("PS1/26 Art. 230(1)")
@cites("CRR Art. 223")
@cites("CRR Art. 230")
def apply_collateral(
    exposures: pl.LazyFrame,
    collateral: pl.LazyFrame,
    config: CalculationConfig,
    haircut_calculator: HaircutCalculator,
    build_exposure_lookups_fn: Callable,
    join_collateral_to_lookups_fn: Callable,
    resolve_pledge_from_joined_fn: Callable,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """
    Apply collateral to reduce EAD (SA) or LGD (IRB).

    Pre-computes shared exposure lookups once, then joins ALL lookup columns
    (EAD, currency, maturity) in a single pass of 3 joins. Pledge resolution
    and currency/maturity derivation operate on pre-joined columns — no
    additional joins needed.

    Args:
        exposures: Exposures with ead_gross
        collateral: Collateral data
        config: Calculation configuration
        haircut_calculator: HaircutCalculator instance
        build_exposure_lookups_fn: Function to build exposure lookups
        join_collateral_to_lookups_fn: Function to join collateral to lookups
        resolve_pledge_from_joined_fn: Function to resolve pledge percentages

    Returns:
        Exposures with collateral effects applied
    """
    # Tag each exposure with its AIRB-pool membership so downstream pro-rata
    # bases can be split into AIRB and non-AIRB pools. CRR Art. 181 / Basel 3.1
    # Art. 169A: AIRB own LGD already reflects collateral, so collateral
    # incorporated in the model must not also be allocated to non-AIRB
    # exposures of the same counterparty.
    schema_names = set(exposures.collect_schema().names())

    # Graceful fallback for direct unit-test callers that hand-build the
    # exposures frame without going through _initialize_ead.  In production
    # both columns are always present.  For pure on-BS rows the defaults
    # produce identical behaviour to the explicit columns, so existing
    # tests stay green without modification.
    fallback_cols: list[pl.Expr] = []
    if "ead_for_crm" not in schema_names:
        fallback_cols.append(pl.col("ead_gross").alias("ead_for_crm"))
    if "effective_ccf" not in schema_names:
        fallback_cols.append(pl.lit(1.0).alias("effective_ccf"))
    if fallback_cols:
        exposures = exposures.with_columns(fallback_cols)
        schema_names |= {expr.meta.output_name() for expr in fallback_cols}

    # S9h: resolve the pack once; the collateral-LGD regime branches downstream
    # (haircut maturity bands, AIRB pool membership, FSE split, Art. 230(2) sub-rows)
    # read honest cited Features off it instead of a single config.is_basel_3_1 bool.
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack

    # CRR Art. 223(5) FCCM exposure volatility haircut (HE). Computed once on
    # the exposure frame so the SA branch in ``_apply_collateral_unified`` can
    # gross E by (1 + HE). Non-SFT / cash / standard-loan rows yield HE = 0.
    exposures = haircut_calculator.apply_exposure_haircut(
        exposures,
        resolved_pack.feature("collateral_haircut_maturity_bands_revised"),
        pack=resolved_pack,
    )

    exposures = exposures.with_columns(
        airb_lgd_preserved_expr(config, schema_names, pack=resolved_pack).alias("_is_airb_pool")
    )

    # Pre-compute shared exposure lookups once
    direct_lookup, facility_lookup, cp_lookup = build_exposure_lookups_fn(exposures)

    # Materialise the small lookup frames in parallel to prevent plan-tree
    # duplication. Each lookup is referenced in multiple downstream joins;
    # without this, Polars re-evaluates the group_by/select at each reference.
    # collect_all runs all 3 concurrently and enables CSE on shared upstream.
    direct_df, facility_df, cp_df = pl.collect_all([direct_lookup, facility_lookup, cp_lookup])
    direct_lookup = direct_df.lazy()
    facility_lookup = facility_df.lazy()
    cp_lookup = cp_df.lazy()

    # Derive pool-aware counterparty EAD totals from the lookups. Unflagged
    # collateral pro-rates over the non-AIRB pool only; flagged collateral
    # (is_airb_model_collateral=True) pro-rates over the AIRB pool only.
    # Facility-level subtree totals are derived per-ancestor inside
    # ``_apply_collateral_unified`` (``_cascade_facility_collateral``) so that
    # collateral pledged at any ancestor facility cascades over its whole
    # descendant subtree for nested facility hierarchies.
    cp_ead_totals = cp_lookup.select(
        pl.col("_ben_ref_cp").alias("counterparty_reference"),
        pl.col("_ead_cp").alias("_cp_ead_total"),
        pl.col("_ead_cp_airb").alias("_cp_ead_total_airb"),
        pl.col("_ead_cp_non_airb").alias("_cp_ead_total_non_airb"),
    )

    # Single pass: join all lookup columns (EAD, currency, maturity)
    collateral = join_collateral_to_lookups_fn(
        collateral, direct_lookup, facility_lookup, cp_lookup
    )

    # Resolve pledge_percentage → market_value (uses pre-joined _beneficiary_ead)
    collateral = resolve_pledge_from_joined_fn(collateral)

    # Apply haircuts to collateral (no longer needs exposures)
    adjusted_collateral = haircut_calculator.apply_haircuts(collateral, config, pack=pack)

    # Apply maturity mismatch using actual exposure maturity (Art. 238)
    adjusted_collateral = haircut_calculator.apply_maturity_mismatch(adjusted_collateral, config)

    # Opt-in audit cache: persist the per-collateral haircut frame for inspection.
    # No-op unless config.audit_cache_dir is set. Surfaces fx_haircut /
    # collateral_haircut / value_after_haircut / value_after_maturity_adj — the
    # diagnostic columns users need to confirm whether H_fx is firing on a row.
    sink_audit(adjusted_collateral, config, "collateral_haircuts")

    return _apply_collateral_unified(
        exposures,
        adjusted_collateral,
        config,
        cp_ead_totals,
        pack=resolved_pack,
    )
sft_bundle_to_exposures — src/rwa_calc/engine/sft/fccm.py:110
@cites("CRR Art. 220")
@cites("CRR Art. 223")
@cites("CRR Art. 224")
@cites("CRR Art. 226")
@cites("CRR Art. 271")
@cites("CRR Art. 285")
def sft_bundle_to_exposures(
    raw_sft: RawSFTBundle,
    reporting_date: date,
    rulepack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """Shape FCCM SFT EADs into synthetic exposure rows from the lean SFT bundle.

    The sole FCCM entry point (SFT/FCCM separation): consumes the dedicated
    :class:`RawSFTBundle` (``RawDataBundle.sft``). The SFT/derivative
    discrimination lives in the *input bundle* now, not in any in-engine
    ``transaction_type`` split:

    - Every trade row is an SFT (no ``transaction_type`` filter): the whole
      ``raw_sft.trades`` frame is in scope.
    - The netting-set ``counterparty_reference`` is denormalised onto the trade
      row (FCCM scope is single-trade single-counterparty netting sets,
      Art. 220(1)(a)), so the NS-grain counterparty frame is derived from the
      trades themselves rather than a separate netting-set table.
    - Collateral is OPTIONAL (``raw_sft.collateral is None`` for an
      uncollateralised SFT, the common case): a missing collateral leaf yields a
      zero collateral term (CVA·(1−HC−HFX) = 0), exactly as an empty
      ``ccr_collateral`` frame would.

    Each emitted synthetic exposure row carries the FCCM provenance:
    ``exposure_reference = "ccr__<netting_set_id>"``, ``risk_type = "CCR_SFT"``,
    ``ccr_method = "fccm_sft"``, ``drawn_amount = E*``, ``ead_ccr = E*``.

    Args:
        raw_sft: The SFT (FCCM) input bundle — every trade row is an SFT with the
            denormalised netting-set counterparty; collateral optional.
        reporting_date: As-of date; written to ``value_date``.
        rulepack: The resolved RUN rulepack supplying the Art. 162 effective-
            maturity floors / regime gate for the ``ccr_effective_maturity``
            carrier. ``None`` (the back-compat default used by direct unit /
            acceptance calls) falls back to the module-level CRR ``_PACK``; the
            stage adapter threads the run pack so production runs are regime-
            correct.

    Returns:
        LazyFrame at netting-set grain. Empty (zero-row) frame when the trades
        bundle is empty.

    References:
        CRR Art. 271(2); Art. 220(1)(a); Art. 223(5); Art. 224 Table 1;
        Art. 224(2)(b); Art. 226; Art. 285(2)-(5).
    """
    sft_trades_lf = raw_sft.trades.sft_trades
    # Counterparty is denormalised onto the trade — collapse to NS grain. The
    # ``first()`` aggregation is exact under the single-CP-per-NS scope
    # (Art. 220(1)(a)); should a future netting set span counterparties the
    # FCCM scope itself would need revisiting.
    ns_counterparty_lf = sft_trades_lf.group_by("netting_set_id").agg(
        pl.col("counterparty_reference").first()
    )
    ccr_collateral_lf = (
        raw_sft.collateral.sft_collateral if raw_sft.collateral is not None else None
    )
    return _build_sft_exposure_rows(
        sft_trades_lf=sft_trades_lf,
        ns_counterparty_lf=ns_counterparty_lf,
        ccr_collateral_lf=ccr_collateral_lf,
        reporting_date=reporting_date,
        pack=rulepack if rulepack is not None else _PACK,
    )

CRR Art. 224 — Supervisory volatility adjustment under the Financial Collateral Comprehensive Method

get_haircut_table — src/rwa_calc/engine/crm/haircut_tables.py:313
@cites("CRR Art. 224")
def get_haircut_table(is_basel_3_1: bool = False) -> pl.DataFrame:
    """
    Get collateral haircut lookup table for the given framework.

    Args:
        is_basel_3_1: True for Basel 3.1 haircuts (CRE22.52-53), False for CRR (Art. 224)

    Returns:
        DataFrame with columns: collateral_type, cqs, maturity_band, haircut, is_main_index
    """
    return _create_haircut_df(is_basel_3_1=is_basel_3_1)
get_maturity_band — src/rwa_calc/engine/crm/haircut_tables.py:327
@cites("CRR Art. 224")
def get_maturity_band(residual_maturity_years: float, is_basel_3_1: bool = False) -> str:
    """
    Determine maturity band from residual maturity.

    CRR uses 3 bands: 0-1y, 1-5y, 5y+
    Basel 3.1 uses 5 bands: 0-1y, 1-3y, 3-5y, 5-10y, 10y+

    Args:
        residual_maturity_years: Residual maturity in years
        is_basel_3_1: True for Basel 3.1 maturity bands

    Returns:
        Maturity band string
    """
    if is_basel_3_1:
        if residual_maturity_years <= 1.0:
            return "0_1y"
        elif residual_maturity_years <= 3.0:
            return "1_3y"
        elif residual_maturity_years <= 5.0:
            return "3_5y"
        elif residual_maturity_years <= 10.0:
            return "5_10y"
        else:
            return "10y_plus"
    else:
        if residual_maturity_years <= 1.0:
            return "0_1y"
        elif residual_maturity_years <= 5.0:
            return "1_5y"
        else:
            return "5y_plus"
apply_haircuts — src/rwa_calc/engine/crm/haircuts.py:121
    @cites("CRR Art. 224")
    def apply_haircuts(
        self,
        collateral: pl.LazyFrame,
        config: CalculationConfig,
        *,
        pack: ResolvedRulepack | None = None,
    ) -> pl.LazyFrame:
        """
        Apply haircuts to collateral.

        Expects exposure_currency and exposure_maturity columns to already be
        present on collateral (joined via _join_collateral_to_lookups before calling).

        Args:
            collateral: Collateral data with market values, exposure_currency, exposure_maturity
            config: Calculation configuration

        Returns:
            LazyFrame with haircut-adjusted collateral values
        """
        # Bootstrap: _resolve_pack_for_haircut needs a regime hint only for its
        # no-config fallback; in apply_haircuts config is always present, so the
        # resolved pack's regime matches config. The maturity-band GATE then reads
        # the cited Feature (S9d) — _maturity_band_expression keeps its bool param
        # (Option B). The haircut VALUES already come from the pack DecisionTable.
        resolved_pack = _resolve_pack_for_haircut(pack, config, config.is_basel_3_1)
        is_b31 = resolved_pack.feature("collateral_haircut_maturity_bands_revised")
        haircut_table = decision_table_df(
            resolved_pack.decision("collateral_haircuts"),
            value_name="haircut",
            key_dtypes={"cqs": pl.Int8},
        )

        # Add maturity band for bond haircut lookup
        collateral = collateral.with_columns(
            [self._maturity_band_expression(is_b31).alias("maturity_band")]
        )

        # Calculate collateral-specific haircut based on type. The framework is
        # selected by the per-call ``haircut_table`` derived from config above.
        collateral = self._apply_collateral_haircuts(collateral, haircut_table)

        # Scale collateral haircut and FX haircut by liquidation period (Art. 226(2))
        # H_m = H_10 × sqrt(T_m / 10)
        # P1.186: derive default liquidation period from exposure_is_sft when no
        # explicit liquidation_period_days is supplied. Non-SFT secured lending
        # defaults to 20 days (Art. 224(2)(a)), SFT/repo to 5 days (Art. 224(2)(c)).
        schema = collateral.collect_schema()
        has_liq_period = "liquidation_period_days" in schema.names()
        has_sft_col = "exposure_is_sft" in schema.names()

        if has_sft_col:
            sft_default = (
                pl.when(pl.col("exposure_is_sft").fill_null(False))
                .then(pl.lit(_LIQUIDATION_PERIOD_REPO))
                .otherwise(pl.lit(_LIQUIDATION_PERIOD_SECURED_LENDING))
            )
        else:
            sft_default = pl.lit(_LIQUIDATION_PERIOD_SECURED_LENDING)

        if has_liq_period:
            liq = pl.col("liquidation_period_days").fill_null(sft_default).cast(pl.Float64)
        else:
            liq = sft_default.cast(pl.Float64)
        scaling_factor = (liq / 10.0).sqrt()

        # Art. 226(1): non-daily mark-to-market / non-daily-remargining adjustment.
        # When revaluation_frequency_days (N_R) > 1, scale the haircut upward by
        # sqrt((N_R + T_m - 1) / T_m). Null or N_R <= 1 leaves the multiplier at 1.0.
        # PS1/26 carries Art. 226(1) forward unchanged so the same gate applies under
        # Basel 3.1 — selection is on collateral input, not framework.
        has_reval_freq = "revaluation_frequency_days" in schema.names()
        if has_reval_freq:
            n_r = pl.col("revaluation_frequency_days").fill_null(1).cast(pl.Float64)
            reval_factor = (
                pl.when(n_r > 1.0).then(((n_r + liq - 1.0) / liq).sqrt()).otherwise(pl.lit(1.0))
            )
        else:
            reval_factor = pl.lit(1.0)

        # Scale collateral haircut by liquidation period, then apply the Art. 226(1)
        # non-daily-revaluation multiplier (order matters per the spec composition
        # H = H_n × sqrt(T_m/10) × sqrt((N_R + T_m - 1)/T_m)).
        # Non-financial collateral (real_estate, receivables, other_physical) uses
        # Art. 230 / PS1/26 Art. 230(2) HC values which are NOT subject to Art. 226
        # liquidation-period scaling — the Art. 230 HC is a credit-quality multiplier
        # tied to the FCM LGD* formula, not a volatility adjustment. Only the
        # Art. 224 financial-collateral haircuts (cash/gold/bonds/equity) scale.
        is_non_financial_hc = (
            pl.col("collateral_type").str.to_lowercase().is_in(NON_FINANCIAL_COLLATERAL_TYPES)
        )
        scaled_haircut = pl.col("collateral_haircut") * scaling_factor * reval_factor
        collateral = collateral.with_columns(
            pl.when(is_non_financial_hc)
            .then(pl.col("collateral_haircut"))
            .otherwise(scaled_haircut)
            .alias("collateral_haircut")
        )

        # Apply FX haircut (Art. 224 Table 4, scaled per Art. 226).
        # Compare pre-FX-conversion currencies: after `FXConverter.convert_*` has
        # rebased values to the reporting currency, the `currency` column is the
        # reporting currency on both sides and a raw comparison would always be
        # false (P1.135). `original_currency` on collateral and `exposure_currency`
        # (sourced from the exposure's `original_currency` in the processor) both
        # carry the true pre-conversion currency pair.
        #
        # Scope: H_fx is the comprehensive-method volatility adjustment for
        # *financial* collateral (Art. 224 Table 4). Funded non-financial
        # collateral (real_estate, receivables, other_physical) is recognised
        # under Art. 230 (Foundation Collateral Method), whose LGD* formula uses
        # the raw collateral value C against C* / C** thresholds with no FX
        # adjustment. Art. 233 H_fx is unfunded-protection only (guarantees /
        # CDS — see engine/crm/guarantees.py). FX risk on Art. 230 collateral
        # is captured upstream by the spot-rate FXConverter rebasing.
        #
        # Art. 227: zero-haircut repos waive ALL volatility adjustments including H_fx.
        fx_base = scalar_value(resolved_pack.scalar_param("fx_haircut"))
        schema_names = collateral.collect_schema().names()
        has_zero_flag = "_is_zero_haircut" in schema_names
        coll_ccy_col = "original_currency" if "original_currency" in schema_names else "currency"
        # Art. 226(1) symmetry: FX haircut is also subject to the non-daily-
        # revaluation scaling — apply ``reval_factor`` after the Art. 226(2)
        # liquidation-period factor, mirroring the collateral haircut path.
        is_financial = ~pl.col("collateral_type").is_in(NON_FINANCIAL_COLLATERAL_TYPES)
        fx_expr = (
            pl.when((pl.col(coll_ccy_col) != pl.col("exposure_currency")) & is_financial)
            .then(pl.lit(fx_base) * scaling_factor * reval_factor)
            .otherwise(pl.lit(0.0))
        )
        if has_zero_flag:
            fx_expr = pl.when(pl.col("_is_zero_haircut")).then(pl.lit(0.0)).otherwise(fx_expr)
        collateral = collateral.with_columns([fx_expr.alias("fx_haircut")])

        # Calculate adjusted value after haircuts
        collateral = collateral.with_columns(
            [
                (
                    pl.col("market_value")
                    * (1.0 - pl.col("collateral_haircut") - pl.col("fx_haircut"))
                )
                .clip(lower_bound=0.0)
                .alias("value_after_haircut"),
            ]
        )

        # Zero out value for ineligible bonds (Art. 197 — CQS 5-6 govt, CQS 4-6 corp)
        if "_bond_ineligible" in collateral.collect_schema().names():
            collateral = collateral.with_columns(
                pl.when(pl.col("_bond_ineligible"))
                .then(pl.lit(0.0))
                .otherwise(pl.col("value_after_haircut"))
                .alias("value_after_haircut")
            )
            # Also enforce is_eligible_financial_collateral = False for ineligible bonds
            if "is_eligible_financial_collateral" in collateral.collect_schema().names():
                collateral = collateral.with_columns(
                    pl.when(pl.col("_bond_ineligible"))
                    .then(pl.lit(False))
                    .otherwise(pl.col("is_eligible_financial_collateral"))
                    .alias("is_eligible_financial_collateral")
                )
            collateral = collateral.drop("_bond_ineligible")

        # Clean up Art. 227 temp column
        if "_is_zero_haircut" in collateral.collect_schema().names():
            collateral = collateral.drop("_is_zero_haircut")

        # Add haircut audit trail
        collateral = collateral.with_columns(
            [
                pl.concat_str(
                    [
                        pl.lit("MV="),
                        pl.col("market_value").round(0).cast(pl.String),
                        pl.lit("; Hc="),
                        (pl.col("collateral_haircut") * 100).round(1).cast(pl.String),
                        pl.lit("%; Hfx="),
                        (pl.col("fx_haircut") * 100).round(1).cast(pl.String),
                        pl.lit("%; Adj="),
                        pl.col("value_after_haircut").round(0).cast(pl.String),
                    ]
                ).alias("haircut_calculation"),
            ]
        )

        return collateral
sft_bundle_to_exposures — src/rwa_calc/engine/sft/fccm.py:111
@cites("CRR Art. 220")
@cites("CRR Art. 223")
@cites("CRR Art. 224")
@cites("CRR Art. 226")
@cites("CRR Art. 271")
@cites("CRR Art. 285")
def sft_bundle_to_exposures(
    raw_sft: RawSFTBundle,
    reporting_date: date,
    rulepack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """Shape FCCM SFT EADs into synthetic exposure rows from the lean SFT bundle.

    The sole FCCM entry point (SFT/FCCM separation): consumes the dedicated
    :class:`RawSFTBundle` (``RawDataBundle.sft``). The SFT/derivative
    discrimination lives in the *input bundle* now, not in any in-engine
    ``transaction_type`` split:

    - Every trade row is an SFT (no ``transaction_type`` filter): the whole
      ``raw_sft.trades`` frame is in scope.
    - The netting-set ``counterparty_reference`` is denormalised onto the trade
      row (FCCM scope is single-trade single-counterparty netting sets,
      Art. 220(1)(a)), so the NS-grain counterparty frame is derived from the
      trades themselves rather than a separate netting-set table.
    - Collateral is OPTIONAL (``raw_sft.collateral is None`` for an
      uncollateralised SFT, the common case): a missing collateral leaf yields a
      zero collateral term (CVA·(1−HC−HFX) = 0), exactly as an empty
      ``ccr_collateral`` frame would.

    Each emitted synthetic exposure row carries the FCCM provenance:
    ``exposure_reference = "ccr__<netting_set_id>"``, ``risk_type = "CCR_SFT"``,
    ``ccr_method = "fccm_sft"``, ``drawn_amount = E*``, ``ead_ccr = E*``.

    Args:
        raw_sft: The SFT (FCCM) input bundle — every trade row is an SFT with the
            denormalised netting-set counterparty; collateral optional.
        reporting_date: As-of date; written to ``value_date``.
        rulepack: The resolved RUN rulepack supplying the Art. 162 effective-
            maturity floors / regime gate for the ``ccr_effective_maturity``
            carrier. ``None`` (the back-compat default used by direct unit /
            acceptance calls) falls back to the module-level CRR ``_PACK``; the
            stage adapter threads the run pack so production runs are regime-
            correct.

    Returns:
        LazyFrame at netting-set grain. Empty (zero-row) frame when the trades
        bundle is empty.

    References:
        CRR Art. 271(2); Art. 220(1)(a); Art. 223(5); Art. 224 Table 1;
        Art. 224(2)(b); Art. 226; Art. 285(2)-(5).
    """
    sft_trades_lf = raw_sft.trades.sft_trades
    # Counterparty is denormalised onto the trade — collapse to NS grain. The
    # ``first()`` aggregation is exact under the single-CP-per-NS scope
    # (Art. 220(1)(a)); should a future netting set span counterparties the
    # FCCM scope itself would need revisiting.
    ns_counterparty_lf = sft_trades_lf.group_by("netting_set_id").agg(
        pl.col("counterparty_reference").first()
    )
    ccr_collateral_lf = (
        raw_sft.collateral.sft_collateral if raw_sft.collateral is not None else None
    )
    return _build_sft_exposure_rows(
        sft_trades_lf=sft_trades_lf,
        ns_counterparty_lf=ns_counterparty_lf,
        ccr_collateral_lf=ccr_collateral_lf,
        reporting_date=reporting_date,
        pack=rulepack if rulepack is not None else _PACK,
    )

CRR Art. 226 — Scaling up of volatility adjustment under the Financial Collateral Comprehensive Method

scale_haircut_for_non_daily_revaluation — src/rwa_calc/engine/crm/haircut_tables.py:123
@cites("CRR Art. 226")
def scale_haircut_for_non_daily_revaluation(
    daily_haircut: float,
    revaluation_freq_days: int,
    holding_period_days: int,
) -> float:
    """Scale a daily-revaluation haircut for non-daily revaluation (Art. 226).

    H = H_daily × sqrt((N_R + T_M − 1) / T_M)

    where N_R = ``revaluation_freq_days`` (actual business days between
    revaluations) and T_M = ``holding_period_days`` (the holding / liquidation
    period in business days). Collapses to the identity when N_R = 1 (daily) —
    the regression anchor for the unmargined-daily SFT path. Art. 226 has no
    numbered paragraphs (do not write "226(2)").

    Args:
        daily_haircut: Haircut already scaled to the holding period at daily
            revaluation (i.e. ``H_10 × sqrt(T_M / 10)``).
        revaluation_freq_days: Actual business days between revaluations (N_R).
        holding_period_days: Holding / liquidation period in business days (T_M).

    Returns:
        The non-daily-scaled haircut; ``daily_haircut`` unchanged when daily,
        when the haircut is zero (cash / ineligible), or for a non-positive
        holding period (defensive div-guard).
    """
    if (
        revaluation_freq_days == 1
        or holding_period_days <= 0
        or math.isclose(daily_haircut, 0.0, abs_tol=1e-10)
    ):
        return daily_haircut
    return daily_haircut * math.sqrt(
        (revaluation_freq_days + holding_period_days - 1) / holding_period_days
    )
sft_bundle_to_exposures — src/rwa_calc/engine/sft/fccm.py:112
@cites("CRR Art. 220")
@cites("CRR Art. 223")
@cites("CRR Art. 224")
@cites("CRR Art. 226")
@cites("CRR Art. 271")
@cites("CRR Art. 285")
def sft_bundle_to_exposures(
    raw_sft: RawSFTBundle,
    reporting_date: date,
    rulepack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """Shape FCCM SFT EADs into synthetic exposure rows from the lean SFT bundle.

    The sole FCCM entry point (SFT/FCCM separation): consumes the dedicated
    :class:`RawSFTBundle` (``RawDataBundle.sft``). The SFT/derivative
    discrimination lives in the *input bundle* now, not in any in-engine
    ``transaction_type`` split:

    - Every trade row is an SFT (no ``transaction_type`` filter): the whole
      ``raw_sft.trades`` frame is in scope.
    - The netting-set ``counterparty_reference`` is denormalised onto the trade
      row (FCCM scope is single-trade single-counterparty netting sets,
      Art. 220(1)(a)), so the NS-grain counterparty frame is derived from the
      trades themselves rather than a separate netting-set table.
    - Collateral is OPTIONAL (``raw_sft.collateral is None`` for an
      uncollateralised SFT, the common case): a missing collateral leaf yields a
      zero collateral term (CVA·(1−HC−HFX) = 0), exactly as an empty
      ``ccr_collateral`` frame would.

    Each emitted synthetic exposure row carries the FCCM provenance:
    ``exposure_reference = "ccr__<netting_set_id>"``, ``risk_type = "CCR_SFT"``,
    ``ccr_method = "fccm_sft"``, ``drawn_amount = E*``, ``ead_ccr = E*``.

    Args:
        raw_sft: The SFT (FCCM) input bundle — every trade row is an SFT with the
            denormalised netting-set counterparty; collateral optional.
        reporting_date: As-of date; written to ``value_date``.
        rulepack: The resolved RUN rulepack supplying the Art. 162 effective-
            maturity floors / regime gate for the ``ccr_effective_maturity``
            carrier. ``None`` (the back-compat default used by direct unit /
            acceptance calls) falls back to the module-level CRR ``_PACK``; the
            stage adapter threads the run pack so production runs are regime-
            correct.

    Returns:
        LazyFrame at netting-set grain. Empty (zero-row) frame when the trades
        bundle is empty.

    References:
        CRR Art. 271(2); Art. 220(1)(a); Art. 223(5); Art. 224 Table 1;
        Art. 224(2)(b); Art. 226; Art. 285(2)-(5).
    """
    sft_trades_lf = raw_sft.trades.sft_trades
    # Counterparty is denormalised onto the trade — collapse to NS grain. The
    # ``first()`` aggregation is exact under the single-CP-per-NS scope
    # (Art. 220(1)(a)); should a future netting set span counterparties the
    # FCCM scope itself would need revisiting.
    ns_counterparty_lf = sft_trades_lf.group_by("netting_set_id").agg(
        pl.col("counterparty_reference").first()
    )
    ccr_collateral_lf = (
        raw_sft.collateral.sft_collateral if raw_sft.collateral is not None else None
    )
    return _build_sft_exposure_rows(
        sft_trades_lf=sft_trades_lf,
        ns_counterparty_lf=ns_counterparty_lf,
        ccr_collateral_lf=ccr_collateral_lf,
        reporting_date=reporting_date,
        pack=rulepack if rulepack is not None else _PACK,
    )

CRR Art. 230 — Calculating risk-weighted exposure amounts and expected loss amounts for other eligible collateral under the IRB Approach

apply_collateral — src/rwa_calc/engine/crm/collateral.py:303
@cites("PS1/26 Art. 230(2)")
@cites("PS1/26 Art. 230(1)")
@cites("CRR Art. 223")
@cites("CRR Art. 230")
def apply_collateral(
    exposures: pl.LazyFrame,
    collateral: pl.LazyFrame,
    config: CalculationConfig,
    haircut_calculator: HaircutCalculator,
    build_exposure_lookups_fn: Callable,
    join_collateral_to_lookups_fn: Callable,
    resolve_pledge_from_joined_fn: Callable,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """
    Apply collateral to reduce EAD (SA) or LGD (IRB).

    Pre-computes shared exposure lookups once, then joins ALL lookup columns
    (EAD, currency, maturity) in a single pass of 3 joins. Pledge resolution
    and currency/maturity derivation operate on pre-joined columns — no
    additional joins needed.

    Args:
        exposures: Exposures with ead_gross
        collateral: Collateral data
        config: Calculation configuration
        haircut_calculator: HaircutCalculator instance
        build_exposure_lookups_fn: Function to build exposure lookups
        join_collateral_to_lookups_fn: Function to join collateral to lookups
        resolve_pledge_from_joined_fn: Function to resolve pledge percentages

    Returns:
        Exposures with collateral effects applied
    """
    # Tag each exposure with its AIRB-pool membership so downstream pro-rata
    # bases can be split into AIRB and non-AIRB pools. CRR Art. 181 / Basel 3.1
    # Art. 169A: AIRB own LGD already reflects collateral, so collateral
    # incorporated in the model must not also be allocated to non-AIRB
    # exposures of the same counterparty.
    schema_names = set(exposures.collect_schema().names())

    # Graceful fallback for direct unit-test callers that hand-build the
    # exposures frame without going through _initialize_ead.  In production
    # both columns are always present.  For pure on-BS rows the defaults
    # produce identical behaviour to the explicit columns, so existing
    # tests stay green without modification.
    fallback_cols: list[pl.Expr] = []
    if "ead_for_crm" not in schema_names:
        fallback_cols.append(pl.col("ead_gross").alias("ead_for_crm"))
    if "effective_ccf" not in schema_names:
        fallback_cols.append(pl.lit(1.0).alias("effective_ccf"))
    if fallback_cols:
        exposures = exposures.with_columns(fallback_cols)
        schema_names |= {expr.meta.output_name() for expr in fallback_cols}

    # S9h: resolve the pack once; the collateral-LGD regime branches downstream
    # (haircut maturity bands, AIRB pool membership, FSE split, Art. 230(2) sub-rows)
    # read honest cited Features off it instead of a single config.is_basel_3_1 bool.
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack

    # CRR Art. 223(5) FCCM exposure volatility haircut (HE). Computed once on
    # the exposure frame so the SA branch in ``_apply_collateral_unified`` can
    # gross E by (1 + HE). Non-SFT / cash / standard-loan rows yield HE = 0.
    exposures = haircut_calculator.apply_exposure_haircut(
        exposures,
        resolved_pack.feature("collateral_haircut_maturity_bands_revised"),
        pack=resolved_pack,
    )

    exposures = exposures.with_columns(
        airb_lgd_preserved_expr(config, schema_names, pack=resolved_pack).alias("_is_airb_pool")
    )

    # Pre-compute shared exposure lookups once
    direct_lookup, facility_lookup, cp_lookup = build_exposure_lookups_fn(exposures)

    # Materialise the small lookup frames in parallel to prevent plan-tree
    # duplication. Each lookup is referenced in multiple downstream joins;
    # without this, Polars re-evaluates the group_by/select at each reference.
    # collect_all runs all 3 concurrently and enables CSE on shared upstream.
    direct_df, facility_df, cp_df = pl.collect_all([direct_lookup, facility_lookup, cp_lookup])
    direct_lookup = direct_df.lazy()
    facility_lookup = facility_df.lazy()
    cp_lookup = cp_df.lazy()

    # Derive pool-aware counterparty EAD totals from the lookups. Unflagged
    # collateral pro-rates over the non-AIRB pool only; flagged collateral
    # (is_airb_model_collateral=True) pro-rates over the AIRB pool only.
    # Facility-level subtree totals are derived per-ancestor inside
    # ``_apply_collateral_unified`` (``_cascade_facility_collateral``) so that
    # collateral pledged at any ancestor facility cascades over its whole
    # descendant subtree for nested facility hierarchies.
    cp_ead_totals = cp_lookup.select(
        pl.col("_ben_ref_cp").alias("counterparty_reference"),
        pl.col("_ead_cp").alias("_cp_ead_total"),
        pl.col("_ead_cp_airb").alias("_cp_ead_total_airb"),
        pl.col("_ead_cp_non_airb").alias("_cp_ead_total_non_airb"),
    )

    # Single pass: join all lookup columns (EAD, currency, maturity)
    collateral = join_collateral_to_lookups_fn(
        collateral, direct_lookup, facility_lookup, cp_lookup
    )

    # Resolve pledge_percentage → market_value (uses pre-joined _beneficiary_ead)
    collateral = resolve_pledge_from_joined_fn(collateral)

    # Apply haircuts to collateral (no longer needs exposures)
    adjusted_collateral = haircut_calculator.apply_haircuts(collateral, config, pack=pack)

    # Apply maturity mismatch using actual exposure maturity (Art. 238)
    adjusted_collateral = haircut_calculator.apply_maturity_mismatch(adjusted_collateral, config)

    # Opt-in audit cache: persist the per-collateral haircut frame for inspection.
    # No-op unless config.audit_cache_dir is set. Surfaces fx_haircut /
    # collateral_haircut / value_after_haircut / value_after_maturity_adj — the
    # diagnostic columns users need to confirm whether H_fx is firing on a row.
    sink_audit(adjusted_collateral, config, "collateral_haircuts")

    return _apply_collateral_unified(
        exposures,
        adjusted_collateral,
        config,
        cp_ead_totals,
        pack=resolved_pack,
    )
allocate_links — src/rwa_calc/engine/crm/link_allocation.py:89
    @cites("CRR Art. 230")
    @cites("CRR Art. 231")
    def allocate_links(
        self,
        exposures: pl.LazyFrame,
        collateral: pl.LazyFrame | None,
        collateral_links: pl.LazyFrame | None,
        config: CalculationConfig,
    ) -> CollateralLinkAllocation:
        """Expand ``collateral_links`` into per-beneficiary collateral slices.

        Returns the original collateral unchanged when no usable links table is
        supplied (the single-beneficiary path). Never raises.
        """
        if collateral is None or collateral_links is None:
            # Absent collateral stays None — never an empty-frame sentinel.
            return CollateralLinkAllocation(collateral=collateral, audit=None)

        coll_cols = collateral.collect_schema().names()
        link_cols = set(collateral_links.collect_schema().names())
        required = {"collateral_reference", "beneficiary_type", "beneficiary_reference"}
        if "collateral_reference" not in coll_cols or not required.issubset(link_cols):
            return CollateralLinkAllocation(collateral=collateral, audit=None)

        demand_metric = self._beneficiary_demand_metric(exposures)
        links = self._resolve_links(collateral_links, collateral, demand_metric, link_cols)
        links = self._allocate_slices(links)

        expanded = self._build_expanded_collateral(collateral, links, coll_cols)
        passthrough = collateral.join(
            collateral_links.select(pl.col("collateral_reference").cast(pl.String)).unique(),
            on="collateral_reference",
            how="anti",
        )
        merged = pl.concat([passthrough, expanded], how="vertical_relaxed")

        audit = links.select(
            pl.col("collateral_reference"),
            pl.col("beneficiary_type"),
            pl.col("beneficiary_reference"),
            pl.col("_demand").alias("beneficiary_demand"),
            pl.col("_metric").alias("rank_metric"),
            pl.col("_value").alias("collateral_value"),
            pl.col("_slice").alias("allocated_value"),
        )
        return CollateralLinkAllocation(collateral=merged, audit=audit)

CRR Art. 231 — Calculating risk-weighted exposure amounts and expected loss amounts in the case of mixed pools of collateral

allocate_links — src/rwa_calc/engine/crm/link_allocation.py:90
    @cites("CRR Art. 230")
    @cites("CRR Art. 231")
    def allocate_links(
        self,
        exposures: pl.LazyFrame,
        collateral: pl.LazyFrame | None,
        collateral_links: pl.LazyFrame | None,
        config: CalculationConfig,
    ) -> CollateralLinkAllocation:
        """Expand ``collateral_links`` into per-beneficiary collateral slices.

        Returns the original collateral unchanged when no usable links table is
        supplied (the single-beneficiary path). Never raises.
        """
        if collateral is None or collateral_links is None:
            # Absent collateral stays None — never an empty-frame sentinel.
            return CollateralLinkAllocation(collateral=collateral, audit=None)

        coll_cols = collateral.collect_schema().names()
        link_cols = set(collateral_links.collect_schema().names())
        required = {"collateral_reference", "beneficiary_type", "beneficiary_reference"}
        if "collateral_reference" not in coll_cols or not required.issubset(link_cols):
            return CollateralLinkAllocation(collateral=collateral, audit=None)

        demand_metric = self._beneficiary_demand_metric(exposures)
        links = self._resolve_links(collateral_links, collateral, demand_metric, link_cols)
        links = self._allocate_slices(links)

        expanded = self._build_expanded_collateral(collateral, links, coll_cols)
        passthrough = collateral.join(
            collateral_links.select(pl.col("collateral_reference").cast(pl.String)).unique(),
            on="collateral_reference",
            how="anti",
        )
        merged = pl.concat([passthrough, expanded], how="vertical_relaxed")

        audit = links.select(
            pl.col("collateral_reference"),
            pl.col("beneficiary_type"),
            pl.col("beneficiary_reference"),
            pl.col("_demand").alias("beneficiary_demand"),
            pl.col("_metric").alias("rank_metric"),
            pl.col("_value").alias("collateral_value"),
            pl.col("_slice").alias("allocated_value"),
        )
        return CollateralLinkAllocation(collateral=merged, audit=audit)

CRR Art. 232 — Other funded credit protection

compute_life_insurance_columns — src/rwa_calc/engine/crm/life_insurance.py:76
@cites("CRR Art. 232")
def compute_life_insurance_columns(
    exposures: pl.LazyFrame,
    collateral: pl.LazyFrame | None,
    config: CalculationConfig,
) -> pl.LazyFrame:
    """Compute life insurance CRM columns on the exposure frame.

    Aggregates eligible life insurance collateral per exposure and sets:
    - life_ins_collateral_value: total surrender value allocated to this exposure
    - life_ins_secured_rw: value-weighted mapped risk weight per Art. 232

    Does NOT modify EAD columns. The SA calculator uses these columns
    for risk weight blending via _apply_life_insurance_rw_mapping().

    Args:
        exposures: Exposure frame with ead_gross, exposure_reference, etc.
        collateral: Collateral frame (may be None if no collateral).
        config: Calculation configuration.

    Returns:
        Exposure frame with life_ins_collateral_value and life_ins_secured_rw columns.
    """
    if collateral is None:
        return _add_default_life_ins_columns(exposures)

    # Filter to life insurance collateral only
    coll_schema = collateral.collect_schema()
    ctype_col = "collateral_type"
    if ctype_col not in coll_schema.names():
        return _add_default_life_ins_columns(exposures)

    li_coll = collateral.filter(
        pl.col(ctype_col).str.to_lowercase().is_in(LIFE_INSURANCE_COLLATERAL_TYPES)
    )

    # Check if insurer_risk_weight column exists
    has_insurer_rw = "insurer_risk_weight" in coll_schema.names()
    if not has_insurer_rw:
        li_coll = li_coll.with_columns(pl.lit(1.00).alias("insurer_risk_weight"))

    # Use market_value as the surrender value (documented convention)
    # Apply Art. 232 mapped RW per item
    li_coll = li_coll.with_columns(_map_insurer_rw_to_secured_rw_expr().alias("_li_item_rw"))

    # Build exposure reference lookups for multi-level matching
    exp_schema = exposures.collect_schema()
    exp_ref_col = (
        "exposure_reference" if "exposure_reference" in exp_schema.names() else "loan_reference"
    )
    ead_col = "ead_gross" if "ead_gross" in exp_schema.names() else "ead"

    # Direct-level: aggregate life insurance value and weighted RW per beneficiary
    li_agg = li_coll.group_by("beneficiary_reference").agg(
        pl.col("market_value").fill_null(0.0).sum().alias("_li_total_value"),
        (pl.col("market_value").fill_null(0.0) * pl.col("_li_item_rw"))
        .sum()
        .alias("_li_weighted_rw"),
    )

    # Join to exposures by beneficiary_reference = exposure_reference
    exposures = exposures.join(
        li_agg,
        left_on=exp_ref_col,
        right_on="beneficiary_reference",
        how="left",
    )

    # Compute per-exposure life insurance columns
    ead = pl.col(ead_col).fill_null(0.0)
    li_value = pl.col("_li_total_value").fill_null(0.0)
    li_wrw = pl.col("_li_weighted_rw").fill_null(0.0)

    # Cap life insurance value at EAD
    capped_value = pl.min_horizontal(li_value, ead)

    # Weighted-average mapped RW
    avg_rw = pl.when(li_value > 0).then(li_wrw / li_value).otherwise(pl.lit(0.0))

    exposures = exposures.with_columns(
        capped_value.alias("life_ins_collateral_value"),
        avg_rw.alias("life_ins_secured_rw"),
    ).drop(["_li_total_value", "_li_weighted_rw"])

    return exposures
apply_life_insurance_rw_mapping — src/rwa_calc/engine/sa/rw_adjustments.py:119
@cites("CRR Art. 232")
def apply_life_insurance_rw_mapping(lf: pl.LazyFrame) -> pl.LazyFrame:
    """Apply Art. 232 life insurance risk weight mapping for SA exposures.

    When life insurance collateral secures an exposure, the secured portion
    receives a mapped risk weight (not direct substitution):
        Insurer RW 20%           -> 20%
        Insurer RW 30% or 50%    -> 35%
        Insurer RW 65%-135%      -> 70%
        Insurer RW 150%          -> 150%

    Blended RW = secured_pct x mapped_rw + unsecured_pct x exposure_rw

    This function is a no-op when no life insurance collateral is present.
    """
    ead = pl.col("ead_final").fill_null(0.0)
    li_value = pl.col("life_ins_collateral_value").fill_null(0.0)
    li_rw = pl.col("life_ins_secured_rw").fill_null(0.0)

    # Secured percentage (capped at 100%)
    secured_pct = pl.when(ead > 0).then((li_value / ead).clip(0.0, 1.0)).otherwise(0.0)
    unsecured_pct = pl.lit(1.0) - secured_pct

    # Blended risk weight: no floor — Art. 232 has no 20% floor like FCSM
    blended_rw = secured_pct * li_rw + unsecured_pct * pl.col("risk_weight")

    # Only apply when there is actual life insurance collateral
    has_li = li_value > 0

    return lf.with_columns(
        pl.when(has_li).then(blended_rw).otherwise(pl.col("risk_weight")).alias("risk_weight"),
    )

CRR Art. 234 — Calculating risk-weighted exposure amounts and expected loss amounts in the event of partial protection and tranching

_build_remainder_sub_rows — src/rwa_calc/engine/crm/guarantees.py:693
@cites("CRR Art. 234")
def _build_remainder_sub_rows(multi_joined: pl.LazyFrame) -> pl.LazyFrame:
    """
    Build the borrower-retained remainder sub-rows (uncovered portion).

    Default (first-loss attach, CRR Art. 235): the protection covers loss band
    ``[0, G*)`` and the borrower retains a single senior remainder ``[G*, EAD]``
    emitted as one ``__REM`` row.

    CRR Art. 234 (tranched coverage): when the guarantee carries an
    ``attachment_amount`` (a) and ``detachment_amount`` (d), the protection
    attaches to the mezzanine band ``[a, d)`` instead of first loss. The
    borrower then retains TWO tranches at its own obligor risk weight:
    a first-loss tranche ``[0, a)`` (``__REM_FL``) and a senior tranche
    ``[d, EAD]`` (``__REM_SEN``). Both retained tranches carry a null
    ``guarantor_reference`` so downstream SA/IRB risk-weight the obligor.

    Tranche widths compose AFTER the existing FX / restructuring / maturity
    mismatch haircuts have reduced ``amount_covered`` to G* (the protected
    width on the guarantor sub-row); ``_total_effective`` is that post-haircut
    capped coverage. When ``attachment_amount`` is null behaviour is unchanged.

    References:
        CRR Art. 234: tranching of credit protection (attachment/detachment).
        CRR Art. 235: SA risk-weight substitution on the protected tranche.
    """
    remainder = (
        multi_joined.sort("parent_exposure_reference", "guarantor")
        .group_by("parent_exposure_reference", maintain_order=True)
        .first()
    )
    schema_names = remainder.collect_schema().names()
    has_tranching = "attachment_amount" in schema_names

    # Total borrower-retained EAD (uncovered portion across all guarantors).
    retained_total = pl.col("ead_after_collateral") - pl.col("_total_effective")

    if not has_tranching:
        return _retained_tranche_rows(remainder, schema_names, retained_total, "__REM")

    # CRR Art. 234: attachment a (null/0 => first-loss). Detachment d defaults to
    # a + protected width so a null detachment collapses to the legacy split.
    attach = pl.col("attachment_amount").fill_null(0.0)
    detach = pl.col("detachment_amount").fill_null(attach + pl.col("_total_effective"))

    # First-loss tranche [0, a) and senior tranche [d, EAD]. Clip widths to the
    # exposure EAD to guard against attachment/detachment overshoot.
    first_loss_width = attach.clip(lower_bound=0.0, upper_bound=pl.col("ead_after_collateral"))
    senior_width = (pl.col("ead_after_collateral") - detach).clip(lower_bound=0.0)

    is_tranched = pl.col("attachment_amount").is_not_null() & (pl.col("attachment_amount") > 0.0)

    legacy_rows = _retained_tranche_rows(
        remainder.filter(~is_tranched), schema_names, retained_total, "__REM"
    )
    first_loss_rows = _retained_tranche_rows(
        remainder.filter(is_tranched), schema_names, first_loss_width, "__REM_FL"
    )
    senior_rows = _retained_tranche_rows(
        remainder.filter(is_tranched), schema_names, senior_width, "__REM_SEN"
    )
    return pl.concat([legacy_rows, first_loss_rows, senior_rows], how="diagonal_relaxed")

CRR Art. 235 — Calculating risk-weighted exposure amounts under the Standardised Approach

_compute_guarantor_rw_sa — src/rwa_calc/engine/irb/guarantee.py:201
@cites("CRR Art. 122")
@cites("CRR Art. 235")
def _compute_guarantor_rw_sa(
    lf: pl.LazyFrame,
    cols: list[str],
    config: CalculationConfig,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """Compute the guarantor's SA risk weight via the shared builder.

    Compiles ``build_guarantor_rw_expr`` (data/tables/guarantor_rw.py) with
    the IRB chain's column names — the same branch chain and order as the
    SA-side twin (engine/sa/namespace.py::_build_guarantor_rw_expr). This
    closes the IRB-guarantor PSE / RGLA substitution gap (the recorded
    Phase 4 fix) plus the IO 0%, named-MDB 0% and MDB Table 2B closures.
    """

    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack

    # Ensure guarantor_exposure_class is available (set by CRM processor;
    # fallback for unit tests that construct LazyFrames directly)
    if "guarantor_exposure_class" not in cols:
        from rwa_calc.engine.entity_class_maps import ENTITY_TYPE_TO_SA_CLASS

        lf = lf.with_columns(
            pl.col("guarantor_entity_type")
            .fill_null("")
            .replace_strict(ENTITY_TYPE_TO_SA_CLASS, default="")
            .alias("guarantor_exposure_class"),
        )
    if "guarantor_is_ccp_client_cleared" not in cols:
        lf = lf.with_columns(
            pl.lit(None).cast(pl.Boolean).alias("guarantor_is_ccp_client_cleared"),
        )
    # B31 SCRA dispatch fallback: ensure ``guarantor_scra_grade`` is referenceable
    # by ``build_institution_guarantor_rw_expr``. The CRM processor populates this
    # column from counterparties.scra_grade (engine/crm/guarantees.py); fall back
    # to null for unit tests that construct LazyFrames directly without going
    # through the CRM join.
    if "guarantor_scra_grade" not in cols:
        lf = lf.with_columns(
            pl.lit(None).cast(pl.String).alias("guarantor_scra_grade"),
        )

    _gec = pl.col("guarantor_exposure_class").fill_null("")

    # Art. 114(4)/(7): Domestic CGCB guarantors -> 0% RW regardless of CQS.
    # Evaluate the domestic-currency test against the guarantee currency (the
    # currency of the substituted exposure to the sovereign); the Art. 233(3) 8%
    # FX haircut separately handles any mismatch between the guarantee and the
    # underlying exposure. Fall back to the exposure's pre-FX denomination when
    # `guarantee_currency` is missing (legacy / no-guarantee rows).
    _irb_schema_names = lf.collect_schema().names()
    _has_country = "guarantor_country_code" in _irb_schema_names
    _has_exposure_ccy_irb = (
        "original_currency" in _irb_schema_names or "currency" in _irb_schema_names
    )
    _has_guarantee_ccy_irb = "guarantee_currency" in _irb_schema_names
    if _has_guarantee_ccy_irb and _has_exposure_ccy_irb:
        _ccy_expr_irb = pl.col("guarantee_currency").fill_null(
            denomination_currency_expr(_irb_schema_names)
        )
    elif _has_guarantee_ccy_irb:
        _ccy_expr_irb = pl.col("guarantee_currency")
    elif _has_exposure_ccy_irb:
        _ccy_expr_irb = denomination_currency_expr(_irb_schema_names)
    else:
        _ccy_expr_irb = None

    _is_domestic_guarantor = (
        build_domestic_cgcb_guarantor_expr("guarantor_country_code", _ccy_expr_irb)
        if _has_country and _ccy_expr_irb is not None
        else pl.lit(False)
    )

    # The shared expression's unrated PSE/RGLA fallback reads the guarantor
    # country column; ensure it is referenceable for direct (non-pipeline)
    # invocation, mirroring the ccp / scra fallbacks above. The pipeline
    # always carries it (joined by engine/crm/guarantees.py).
    if not _has_country:
        lf = lf.with_columns(
            pl.lit(None).cast(pl.String).alias("guarantor_country_code"),
        )

    return lf.with_columns(
        build_guarantor_rw_expr(
            exposure_class_col="guarantor_exposure_class",
            entity_type_col="guarantor_entity_type",
            cqs_col="guarantor_cqs",
            country_code_col="guarantor_country_code",
            ccp_client_cleared_col="guarantor_is_ccp_client_cleared",
            scra_grade_col="guarantor_scra_grade",
            is_basel_3_1=resolved_pack.feature("sa_revised_risk_weight_tables"),
            domestic_cgcb_expr=_is_domestic_guarantor,
            # No borrower-maturity short-term flag is threaded on the IRB
            # path today (the SA twin derives one from its own stage
            # scratch); long-term Table 3 applies throughout.
            short_term_flag_col=None,
            no_guarantee_expr=pl.col("guaranteed_portion").fill_null(0) <= 0,
        ).alias("guarantor_rw_sa"),
    )
build_guarantor_rw_expr — src/rwa_calc/engine/sa/guarantor_rw.py:132
@cites("CRR Art. 114")
@cites("CRR Art. 115")
@cites("CRR Art. 116")
@cites("CRR Art. 117")
@cites("CRR Art. 118")
@cites("CRR Art. 235")
def build_guarantor_rw_expr(
    *,
    exposure_class_col: str,
    entity_type_col: str,
    cqs_col: str,
    country_code_col: str,
    ccp_client_cleared_col: str,
    scra_grade_col: str,
    is_basel_3_1: bool,
    domestic_cgcb_expr: pl.Expr | None = None,
    short_term_flag_col: str | None = None,
    no_guarantee_expr: pl.Expr | None = None,
) -> pl.Expr:
    """Build the full when/then chain that maps a guarantor to its SA RW.

    Dispatches on the guarantor's SA exposure class (derived from
    ``ENTITY_TYPE_TO_SA_CLASS`` by the caller — e.g. the CRM processor)
    rather than regex on entity_type, ensuring all valid entity types are
    covered. Reproduces the SA-side reference chain
    (``engine/sa/rw_adjustments.py::_build_guarantor_rw_expr``) branch-for-branch.

    Branch order (first match wins):
        no guarantee -> null (only when ``no_guarantee_expr`` is supplied)
        domestic CGCB sovereign (Art. 114(4)/(7)) -> 0%
        CGCB CQS table (Art. 114 Table 1)
        CCP (CRR Art. 306, CRE54.14-15)
        International Organisation (Art. 118) -> 0%
        Named MDB (Art. 117(2)) -> 0%
        MDB Table 2B (Art. 117(1))
        Institution (ECRA / SCRA via build_institution_guarantor_rw_expr —
            short-term Art. 120(2) Table 4 when ``short_term_flag_col``
            evaluates True, otherwise long-term Table 3)
        PSE (Art. 116(2) Table 2A, sovereign-derived for unrated)
        RGLA (Art. 115(1)(b) Table 1B, sovereign-derived for unrated)
        Corporate (Art. 122 corporate CQS table)
        else -> null (no substitution)

    The unrated PSE / RGLA fallback is the documented SA-side approximation
    (no guarantor sovereign-CQS join exists in the CRM column production):
    a GB guarantor receives the 20% RGLA / PSE domestic-currency treatment,
    any other country the conservative 100% unrated default — NOT the full
    Art. 116(1) Table 2 / Art. 115(1)(a) Table 1A sovereign-derived lookup.

    Args:
        exposure_class_col: Name of the guarantor SA exposure-class column
            (e.g. ``guarantor_exposure_class``).
        entity_type_col: Name of the guarantor entity-type column — used for
            the CCP override and the named-MDB (``mdb_named``) carve-out.
        cqs_col: Name of the integer guarantor CQS column.
        country_code_col: Name of the guarantor country-code column — drives
            the unrated PSE / RGLA GB-vs-other approximation.
        ccp_client_cleared_col: Name of the Boolean client-cleared flag
            column (null -> proprietary 2%).
        scra_grade_col: Name of the guarantor SCRA-grade column threaded
            into ``build_institution_guarantor_rw_expr`` for the B31 unrated
            institution dispatch.
        is_basel_3_1: Select PS1/26 tables (institution ECRA / corporate
            Table 6) when True, CRR tables when False. PSE / RGLA / MDB /
            IO / CCP values are framework-identical.
        domestic_cgcb_expr: Caller-supplied Art. 114(4)/(7) domestic-currency
            test (SA and IRB derive domesticity differently). ``None``
            disables the domestic 0% branch (treated as never-domestic).
        short_term_flag_col: Optional Boolean column routing institution
            guarantors to the Art. 120(2) Table 4 short-term dicts. The IRB
            chain passes ``None`` today.
        no_guarantee_expr: Caller-owned leading guard — rows where it
            evaluates True yield null (no substitution priced). ``None``
            omits the guard (the chain prices every row).

    Returns:
        Float64 Polars expression evaluating to the guarantor's SA RW, or
        null where no substitution treatment exists.
    """
    gec = pl.col(exposure_class_col).fill_null("")
    # PSE/RGLA Art. 116(2)/115(1)(b) unrated fallback: domestic-GB guarantors
    # get the RGLA/PSE 20% domestic-currency treatment; otherwise the
    # conservative 100% PSE/RGLA unrated default applies.
    sovereign_derived_unrated = _pse_rgla_unrated_fallback_expr(country_code_col)

    cgcb_unrated = float(_CGCB_RW[CQS.UNRATED])

    is_domestic_guarantor = domestic_cgcb_expr if domestic_cgcb_expr is not None else pl.lit(False)
    skip_substitution = no_guarantee_expr if no_guarantee_expr is not None else pl.lit(False)

    return (
        pl.when(skip_substitution)
        .then(pl.lit(None).cast(pl.Float64))
        # Art. 114(4)/(7): Domestic sovereign -> 0% regardless of CQS.
        .when((gec == "central_govt_central_bank") & is_domestic_guarantor)
        .then(pl.lit(0.0))
        # CGCB guarantors via CQS (Table 1 — sovereign weights).
        .when(gec == "central_govt_central_bank")
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _CGCB_RW,
                cgcb_unrated,
            )
        )
        # CCP guarantors: 2% proprietary / 4% client-cleared
        # (CRR Art. 306, CRE54.14-15) — overrides institution CQS weights.
        .when(pl.col(entity_type_col) == "ccp")
        .then(
            pl.when(pl.col(ccp_client_cleared_col).fill_null(False))
            .then(pl.lit(_QCCP_CLIENT_CLEARED_RW))
            .otherwise(pl.lit(_QCCP_PROPRIETARY_RW))
        )
        # International Organisation (Art. 118): 0% unconditional.
        .when(gec == "international_organisation")
        .then(pl.lit(float(_IO_ZERO_RW)))
        # Named MDB (Art. 117(2)): 0% unconditional.
        .when((gec == "mdb") & (pl.col(entity_type_col).fill_null("") == "mdb_named"))
        .then(pl.lit(float(_MDB_NAMED_ZERO_RW)))
        # Rated / unrated non-named MDB — Table 2B (Art. 117(1)).
        .when(gec == "mdb")
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _MDB_RW,
                float(_MDB_UNRATED_RW),
            )
        )
        # Institution guarantors — RW driven from institution_rw_crr /
        # institution_rw_b31_ecra so the pack remains the single source of
        # truth. When the short-term flag evaluates True (CRR/PS1/26
        # Art. 120(2)), the short-term Table 4 dicts apply instead.
        .when(gec == "institution")
        .then(
            build_institution_guarantor_rw_expr(
                cqs_col,
                is_basel_3_1,
                short_term_flag_col=short_term_flag_col,
                scra_grade_col=scra_grade_col,
            )
        )
        # PSE guarantors — Art. 116(2) Table 2A for rated, sovereign-derived for unrated.
        .when(gec == "pse")
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _PSE_OWN_RW,
                sovereign_derived_unrated,
            )
        )
        # RGLA guarantors — Art. 115(1)(b) Table 1B for rated, sovereign-derived for unrated.
        .when(gec == "rgla")
        .then(
            _cqs_table_lookup_expr(
                cqs_col,
                _RGLA_OWN_RW,
                sovereign_derived_unrated,
            )
        )
        # Corporate guarantors — Art. 122 corporate CQS table.
        # Basel 3.1 (PRA PS1/26 Art. 122(2) Table 6): CQS3 = 75% (CRR: 100%);
        # PRA retains CQS5 = 150%. Gated on framework so CRR runs are
        # unchanged.
        .when(gec.is_in(["corporate", "corporate_sme"]))
        .then(build_corporate_guarantor_rw_expr(cqs_col, is_basel_3_1))
        .otherwise(pl.lit(None).cast(pl.Float64))
    )

CRR Art. 237 — Maturity mismatch

apply_maturity_mismatch — src/rwa_calc/engine/crm/haircuts.py:661
    @cites("CRR Art. 237")
    @cites("CRR Art. 238")
    def apply_maturity_mismatch(
        self,
        collateral: pl.LazyFrame,
        config: CalculationConfig,
    ) -> pl.LazyFrame:
        """
        Apply maturity mismatch adjustment per CRR Art. 237-238.

        Art. 237(2) ineligibility conditions (protection zeroed when mismatch exists):
        - (a) Residual maturity < 3 months (existing check)
        - (b) Original maturity of protection < 1 year
        - Art. 162(3) exposures with 1-day IRB maturity floor: ANY mismatch makes
          protection ineligible (repos/SFTs with daily margining)

        Formula (Art. 238): CVAM = CVA × (t - 0.25) / (T - 0.25)
        where t = collateral residual maturity, T = min(exposure residual maturity, 5).

        Args:
            collateral: Collateral with value_after_haircut, residual_maturity_years,
                and exposure_maturity (Date) columns. Optionally:
                original_maturity_years (Float64) and
                exposure_has_one_day_maturity_floor (Boolean).
            config: Calculation configuration (provides reporting_date)

        Returns:
            LazyFrame with maturity-adjusted collateral values
        """
        reporting_date = config.reporting_date
        coll_schema = collateral.collect_schema()

        # Derive exposure maturity in years from the Date column, capped at 5y, floored at 0.25y
        exposure_maturity_years_expr = (
            (
                (pl.col("exposure_maturity").cast(pl.Date) - pl.lit(reporting_date))
                .dt.total_days()
                .cast(pl.Float64)
                / 365.25
            )
            .clip(lower_bound=0.25, upper_bound=5.0)
            .fill_null(5.0)
        )

        prep_cols = [
            pl.col("residual_maturity_years").fill_null(10.0).alias("coll_maturity"),
            exposure_maturity_years_expr.alias("_exposure_maturity_years"),
        ]

        # Art. 237(2): original maturity of protection — null defaults to >= 1yr (permissive)
        if "original_maturity_years" in coll_schema.names():
            prep_cols.append(
                pl.col("original_maturity_years").fill_null(10.0).alias("_orig_maturity")
            )
        else:
            prep_cols.append(pl.lit(10.0).alias("_orig_maturity"))

        # Art. 162(3): 1-day maturity floor flag — null/absent defaults to False (permissive)
        if "exposure_has_one_day_maturity_floor" in coll_schema.names():
            prep_cols.append(
                pl.col("exposure_has_one_day_maturity_floor")
                .fill_null(False)
                .alias("_has_1d_floor")
            )
        else:
            prep_cols.append(pl.lit(False).alias("_has_1d_floor"))

        collateral = collateral.with_columns(prep_cols)

        # Determine whether a maturity mismatch exists (collateral < exposure)
        has_mismatch = pl.col("coll_maturity") < pl.col("_exposure_maturity_years")

        # Calculate maturity mismatch adjustment per Art. 237-238
        collateral = collateral.with_columns(
            [
                # No adjustment when collateral maturity >= exposure maturity
                pl.when(~has_mismatch)
                .then(pl.lit(1.0))
                # Art. 237(2)(a): No protection when collateral maturity < 3 months
                .when(pl.col("coll_maturity") < 0.25)
                .then(pl.lit(0.0))
                # Art. 237(2): Original maturity of protection < 1 year → ineligible
                .when(pl.col("_orig_maturity") < 1.0)
                .then(pl.lit(0.0))
                # Art. 162(3)/237(2): 1-day M floor exposure → any mismatch makes
                # protection ineligible (repos/SFTs with daily margining)
                .when(pl.col("_has_1d_floor"))
                .then(pl.lit(0.0))
                # CVAM = (t - 0.25) / (T - 0.25) where T = exposure maturity capped at 5y
                .otherwise(
                    (pl.col("coll_maturity") - 0.25) / (pl.col("_exposure_maturity_years") - 0.25)
                )
                .alias("maturity_adjustment_factor"),
            ]
        )

        # Apply maturity adjustment
        collateral = collateral.with_columns(
            [
                (pl.col("value_after_haircut") * pl.col("maturity_adjustment_factor")).alias(
                    "value_after_maturity_adj"
                ),
            ]
        )

        return collateral.drop(["_exposure_maturity_years", "_orig_maturity", "_has_1d_floor"])

CRR Art. 238 — Maturity of credit protection

apply_maturity_mismatch — src/rwa_calc/engine/crm/haircuts.py:662
    @cites("CRR Art. 237")
    @cites("CRR Art. 238")
    def apply_maturity_mismatch(
        self,
        collateral: pl.LazyFrame,
        config: CalculationConfig,
    ) -> pl.LazyFrame:
        """
        Apply maturity mismatch adjustment per CRR Art. 237-238.

        Art. 237(2) ineligibility conditions (protection zeroed when mismatch exists):
        - (a) Residual maturity < 3 months (existing check)
        - (b) Original maturity of protection < 1 year
        - Art. 162(3) exposures with 1-day IRB maturity floor: ANY mismatch makes
          protection ineligible (repos/SFTs with daily margining)

        Formula (Art. 238): CVAM = CVA × (t - 0.25) / (T - 0.25)
        where t = collateral residual maturity, T = min(exposure residual maturity, 5).

        Args:
            collateral: Collateral with value_after_haircut, residual_maturity_years,
                and exposure_maturity (Date) columns. Optionally:
                original_maturity_years (Float64) and
                exposure_has_one_day_maturity_floor (Boolean).
            config: Calculation configuration (provides reporting_date)

        Returns:
            LazyFrame with maturity-adjusted collateral values
        """
        reporting_date = config.reporting_date
        coll_schema = collateral.collect_schema()

        # Derive exposure maturity in years from the Date column, capped at 5y, floored at 0.25y
        exposure_maturity_years_expr = (
            (
                (pl.col("exposure_maturity").cast(pl.Date) - pl.lit(reporting_date))
                .dt.total_days()
                .cast(pl.Float64)
                / 365.25
            )
            .clip(lower_bound=0.25, upper_bound=5.0)
            .fill_null(5.0)
        )

        prep_cols = [
            pl.col("residual_maturity_years").fill_null(10.0).alias("coll_maturity"),
            exposure_maturity_years_expr.alias("_exposure_maturity_years"),
        ]

        # Art. 237(2): original maturity of protection — null defaults to >= 1yr (permissive)
        if "original_maturity_years" in coll_schema.names():
            prep_cols.append(
                pl.col("original_maturity_years").fill_null(10.0).alias("_orig_maturity")
            )
        else:
            prep_cols.append(pl.lit(10.0).alias("_orig_maturity"))

        # Art. 162(3): 1-day maturity floor flag — null/absent defaults to False (permissive)
        if "exposure_has_one_day_maturity_floor" in coll_schema.names():
            prep_cols.append(
                pl.col("exposure_has_one_day_maturity_floor")
                .fill_null(False)
                .alias("_has_1d_floor")
            )
        else:
            prep_cols.append(pl.lit(False).alias("_has_1d_floor"))

        collateral = collateral.with_columns(prep_cols)

        # Determine whether a maturity mismatch exists (collateral < exposure)
        has_mismatch = pl.col("coll_maturity") < pl.col("_exposure_maturity_years")

        # Calculate maturity mismatch adjustment per Art. 237-238
        collateral = collateral.with_columns(
            [
                # No adjustment when collateral maturity >= exposure maturity
                pl.when(~has_mismatch)
                .then(pl.lit(1.0))
                # Art. 237(2)(a): No protection when collateral maturity < 3 months
                .when(pl.col("coll_maturity") < 0.25)
                .then(pl.lit(0.0))
                # Art. 237(2): Original maturity of protection < 1 year → ineligible
                .when(pl.col("_orig_maturity") < 1.0)
                .then(pl.lit(0.0))
                # Art. 162(3)/237(2): 1-day M floor exposure → any mismatch makes
                # protection ineligible (repos/SFTs with daily margining)
                .when(pl.col("_has_1d_floor"))
                .then(pl.lit(0.0))
                # CVAM = (t - 0.25) / (T - 0.25) where T = exposure maturity capped at 5y
                .otherwise(
                    (pl.col("coll_maturity") - 0.25) / (pl.col("_exposure_maturity_years") - 0.25)
                )
                .alias("maturity_adjustment_factor"),
            ]
        )

        # Apply maturity adjustment
        collateral = collateral.with_columns(
            [
                (pl.col("value_after_haircut") * pl.col("maturity_adjustment_factor")).alias(
                    "value_after_maturity_adj"
                ),
            ]
        )

        return collateral.drop(["_exposure_maturity_years", "_orig_maturity", "_has_1d_floor"])

CRR Art. 271 — Determination of the exposure value

sft_bundle_to_exposures — src/rwa_calc/engine/sft/fccm.py:113
@cites("CRR Art. 220")
@cites("CRR Art. 223")
@cites("CRR Art. 224")
@cites("CRR Art. 226")
@cites("CRR Art. 271")
@cites("CRR Art. 285")
def sft_bundle_to_exposures(
    raw_sft: RawSFTBundle,
    reporting_date: date,
    rulepack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """Shape FCCM SFT EADs into synthetic exposure rows from the lean SFT bundle.

    The sole FCCM entry point (SFT/FCCM separation): consumes the dedicated
    :class:`RawSFTBundle` (``RawDataBundle.sft``). The SFT/derivative
    discrimination lives in the *input bundle* now, not in any in-engine
    ``transaction_type`` split:

    - Every trade row is an SFT (no ``transaction_type`` filter): the whole
      ``raw_sft.trades`` frame is in scope.
    - The netting-set ``counterparty_reference`` is denormalised onto the trade
      row (FCCM scope is single-trade single-counterparty netting sets,
      Art. 220(1)(a)), so the NS-grain counterparty frame is derived from the
      trades themselves rather than a separate netting-set table.
    - Collateral is OPTIONAL (``raw_sft.collateral is None`` for an
      uncollateralised SFT, the common case): a missing collateral leaf yields a
      zero collateral term (CVA·(1−HC−HFX) = 0), exactly as an empty
      ``ccr_collateral`` frame would.

    Each emitted synthetic exposure row carries the FCCM provenance:
    ``exposure_reference = "ccr__<netting_set_id>"``, ``risk_type = "CCR_SFT"``,
    ``ccr_method = "fccm_sft"``, ``drawn_amount = E*``, ``ead_ccr = E*``.

    Args:
        raw_sft: The SFT (FCCM) input bundle — every trade row is an SFT with the
            denormalised netting-set counterparty; collateral optional.
        reporting_date: As-of date; written to ``value_date``.
        rulepack: The resolved RUN rulepack supplying the Art. 162 effective-
            maturity floors / regime gate for the ``ccr_effective_maturity``
            carrier. ``None`` (the back-compat default used by direct unit /
            acceptance calls) falls back to the module-level CRR ``_PACK``; the
            stage adapter threads the run pack so production runs are regime-
            correct.

    Returns:
        LazyFrame at netting-set grain. Empty (zero-row) frame when the trades
        bundle is empty.

    References:
        CRR Art. 271(2); Art. 220(1)(a); Art. 223(5); Art. 224 Table 1;
        Art. 224(2)(b); Art. 226; Art. 285(2)-(5).
    """
    sft_trades_lf = raw_sft.trades.sft_trades
    # Counterparty is denormalised onto the trade — collapse to NS grain. The
    # ``first()`` aggregation is exact under the single-CP-per-NS scope
    # (Art. 220(1)(a)); should a future netting set span counterparties the
    # FCCM scope itself would need revisiting.
    ns_counterparty_lf = sft_trades_lf.group_by("netting_set_id").agg(
        pl.col("counterparty_reference").first()
    )
    ccr_collateral_lf = (
        raw_sft.collateral.sft_collateral if raw_sft.collateral is not None else None
    )
    return _build_sft_exposure_rows(
        sft_trades_lf=sft_trades_lf,
        ns_counterparty_lf=ns_counterparty_lf,
        ccr_collateral_lf=ccr_collateral_lf,
        reporting_date=reporting_date,
        pack=rulepack if rulepack is not None else _PACK,
    )

CRR Art. 277 — Transactions with a linear risk profile

assign_ir_maturity_bucket — src/rwa_calc/engine/ccr/hedging_sets.py:56
@cites("CRR Art. 277")
def assign_ir_maturity_bucket(trades: pl.LazyFrame) -> pl.LazyFrame:
    """Assign an IR maturity bucket per CRR Art. 277(2).

    For ``asset_class == "interest_rate"`` rows, derive ``maturity_bucket``
    from ``years_to_maturity``:

        LT_1Y : M < 1
        1Y_5Y : 1 <= M <= 5
        GT_5Y : M > 5

    Non-IR rows receive a null bucket (extended in subsequent batches).

    Args:
        trades: LazyFrame with ``asset_class`` and ``years_to_maturity``
            columns.

    Returns:
        The input LazyFrame with a new ``maturity_bucket: Utf8`` column.

    References:
        CRR Art. 277(2)(a)-(c); BCBS CRE52.32.
    """
    is_ir = pl.col("asset_class") == "interest_rate"
    m = pl.col("years_to_maturity")

    bucket = (
        pl.when(is_ir & (m < 1.0))
        .then(pl.lit("LT_1Y"))
        .when(is_ir & (m <= 5.0))
        .then(pl.lit("1Y_5Y"))
        .when(is_ir & (m > 5.0))
        .then(pl.lit("GT_5Y"))
        .otherwise(pl.lit(None, dtype=pl.Utf8))
        .alias("maturity_bucket")
    )

    return trades.with_columns(bucket)
assign_hedging_set — src/rwa_calc/engine/ccr/hedging_sets.py:96
@cites("CRR Art. 277")
def assign_hedging_set(trades: pl.LazyFrame) -> pl.LazyFrame:
    """Assign a composite ``hedging_set_id`` per CRR Art. 277(1).

    Pipeline-position note: ``years_to_maturity`` must already be on the
    input frame (the upstream maturity-factor stage adds it).

    The hedging-set identifier composes the asset-class short code, the
    netting-set id, the trade currency and the maturity bucket as
    ``"<asset_short>-<netting_set_id>-<currency>-<maturity_bucket>"`` —
    e.g. ``"IR-NS-IR-01-GBP-GT_5Y"``. Non-IR rows receive a null
    ``hedging_set_id`` until the corresponding asset-class batch lands.

    Args:
        trades: LazyFrame with ``asset_class``, ``netting_set_id``,
            ``currency``, ``years_to_maturity`` columns.

    Returns:
        The input LazyFrame with new ``maturity_bucket: Utf8`` and
        ``hedging_set_id: Utf8`` columns.

    References:
        CRR Art. 277(1)-(2); BCBS CRE52.30-32.
    """
    trades = assign_ir_maturity_bucket(trades)

    # Defensive: upstream stages that pre-date the credit/equity/commodity
    # branches may pass frames without ``commodity_type``. The column is
    # required only by the commodity branch and may be safely treated as
    # all-null when absent — Polars evaluates the dispatch ladder eagerly
    # at plan-resolve time, so the column must exist on the schema.
    schema_names = trades.collect_schema().names()
    if "commodity_type" not in schema_names:
        trades = trades.with_columns(pl.lit(None, dtype=pl.Utf8).alias("commodity_type"))

    asset_short = pl.col("asset_class").replace_strict(
        ASSET_CLASS_SHORT_CODE, default=None, return_dtype=pl.Utf8
    )

    # Order-independent currency pair for FX hedging-set keying (Art. 277(3)(a)).
    # ``min/max`` of the two ISO-4217 strings collapses EUR/USD and USD/EUR
    # into a single hedging set per netting set.
    fx_pair = pl.concat_str(
        [
            pl.min_horizontal(pl.col("currency"), pl.col("currency_leg2")),
            pl.lit("/"),
            pl.max_horizontal(pl.col("currency"), pl.col("currency_leg2")),
        ]
    )

    ir_hs = pl.concat_str(
        [
            asset_short,
            pl.col("netting_set_id"),
            pl.col("currency"),
            pl.col("maturity_bucket"),
        ],
        separator="-",
    )

    fx_hs = pl.concat_str(
        [pl.lit("FX"), pl.col("netting_set_id"), fx_pair],
        separator="-",
    )

    credit_hs = pl.concat_str(
        [pl.lit("CR"), pl.col("netting_set_id")],
        separator="-",
    )

    equity_hs = pl.concat_str(
        [pl.lit("EQ"), pl.col("netting_set_id")],
        separator="-",
    )

    commodity_hs = pl.concat_str(
        [pl.lit("CO"), pl.col("netting_set_id"), pl.col("commodity_type")],
        separator="-",
    )

    hedging_set_id = (
        pl.when(pl.col("asset_class") == "interest_rate")
        .then(pl.when(pl.col("maturity_bucket").is_not_null()).then(ir_hs).otherwise(None))
        .when(pl.col("asset_class") == "fx")
        .then(fx_hs)
        .when(pl.col("asset_class") == "credit")
        .then(credit_hs)
        .when(pl.col("asset_class") == "equity")
        .then(equity_hs)
        .when(pl.col("asset_class") == "commodity")
        .then(pl.when(pl.col("commodity_type").is_not_null()).then(commodity_hs).otherwise(None))
        .otherwise(pl.lit(None, dtype=pl.Utf8))
        .alias("hedging_set_id")
    )

    return trades.with_columns(hedging_set_id)
compute_addon_per_asset_class — src/rwa_calc/engine/ccr/pfe.py:147
@cites("CRR Art. 277")
def compute_addon_per_asset_class(trades: pl.LazyFrame) -> pl.LazyFrame:
    """Per-asset-class SA-CCR add-on aggregated from per-trade effective notionals.

    Dispatches to asset-class-specific helpers and unions the results onto a
    keys frame that anchors every ``(netting_set_id, asset_class)`` combination
    present in the input. Asset classes without an implementation (credit /
    equity / commodity) keep their ``asset_class_addon`` as null — that's the
    contract callers downstream depend on.

    Implemented asset classes:

    - ``interest_rate``: three IR maturity buckets aggregated per Art. 277a(1)(a)
      via :func:`_compute_addon_ir`.
    - ``fx``: per-currency-pair hedging sets summed with no cross-set
      correlation per BCBS CRE52.55 via :func:`_compute_addon_fx`.
    - ``credit``: per-entity effective notionals aggregated inside a single
      credit hedging set via the supervisory-correlation formula per
      Art. 277a + Art. 280a via :func:`_compute_addon_credit`.
    - ``equity``: single hedging set per NS with SN/IDX sub-class aggregation
      per Art. 277a + Art. 280b via :func:`_compute_addon_equity`.
    - ``commodity``: five commodity buckets (ELECTRICITY / OIL_GAS / METALS /
      AGRICULTURAL / OTHER) with within-bucket correlation ρ=0.40
      (Art. 280c) and no cross-bucket correlation (CRE52.69) via
      :func:`_compute_addon_commodity`.

    Args:
        trades: LazyFrame at trade grain with at minimum ``netting_set_id``,
            ``asset_class``, ``hedging_set_id``, ``maturity_bucket``,
            ``supervisory_delta``, ``adjusted_notional`` and ``maturity_factor``
            columns.

    Returns:
        LazyFrame with one row per (``netting_set_id``, ``asset_class``)
        and columns ``netting_set_id``, ``asset_class``,
        ``asset_class_addon: Float64``.

    References:
        CRR Art. 277a(1)(a) (IR); CRR Art. 277(3)(a) + BCBS CRE52.55 (FX);
        CRR Art. 277(2)(c) + Art. 277a + Art. 280a (credit);
        CRR Art. 277(2)(d) + Art. 280b (equity);
        CRR Art. 277(3)(b) + Art. 280c + BCBS CRE52.67-69 (commodity);
        CRR Art. 280 Table 1/2 (SF_IR=0.5%, SF_FX=4%, SF_EQ_SN=32%, SF_EQ_IDX=20%,
            SF_CR by quality/index, SF_CM per bucket).
    """
    keys = trades.select(["netting_set_id", "asset_class"]).unique()

    ir_addon = _compute_addon_ir(trades).rename({"asset_class_addon": "_ir_addon"})
    fx_addon = _compute_addon_fx(trades).rename({"asset_class_addon": "_fx_addon"})
    credit_addon = _compute_addon_credit(trades).rename({"asset_class_addon": "_credit_addon"})
    eq_addon = _compute_addon_equity(trades).rename({"asset_class_addon": "_eq_addon"})
    co_addon = _compute_addon_commodity(trades).rename({"asset_class_addon": "_co_addon"})

    return (
        keys.join(ir_addon, on=["netting_set_id", "asset_class"], how="left")
        .join(fx_addon, on=["netting_set_id", "asset_class"], how="left")
        .join(credit_addon, on=["netting_set_id", "asset_class"], how="left")
        .join(eq_addon, on=["netting_set_id", "asset_class"], how="left")
        .join(co_addon, on=["netting_set_id", "asset_class"], how="left")
        .with_columns(
            pl.coalesce(
                pl.col("_ir_addon"),
                pl.col("_fx_addon"),
                pl.col("_credit_addon"),
                pl.col("_eq_addon"),
                pl.col("_co_addon"),
            ).alias("asset_class_addon")
        )
        .select(["netting_set_id", "asset_class", "asset_class_addon"])
    )

CRR Art. 278 — Transactions with a non-linear risk profile

compute_pfe — src/rwa_calc/engine/ccr/pfe.py:63
@cites("CRR Art. 278")
def compute_pfe(
    netting_sets: pl.LazyFrame,
    config: CCRConfig | None = None,
) -> pl.LazyFrame:
    """SA-CCR PFE multiplier and aggregate PFE per CRR Art. 278(3).

    Implements the netting-set-grain PFE composition layer:

        multiplier = min(1, F + (1 − F) × exp((V − C) / (2 × (1 − F) × AddOn_agg)))
        pfe_addon  = multiplier × AddOn_aggregate          (Art. 278(1))
        rc_unmarg  = max(V_net − C_net, 0)                 (Art. 275(1))
        ead_ccr    = α × (rc_unmarg + pfe_addon)           (Art. 274(2))

    where ``F = 0.05`` (``PFE_MULTIPLIER_FLOOR_F``) and the ``2`` in the
    denominator is ``PFE_AGGREGATE_DENOM_COEFF``. The ``min(1, ...)`` cap
    binds whenever ``V − C ≥ 0`` (over-collateralised / in-the-money).

    Args:
        netting_sets: LazyFrame at netting-set grain with at minimum
            ``v_net: Float64``, ``c_net: Float64`` and
            ``addon_aggregate: Float64`` columns.
        config: Optional CCRConfig; when provided ``config.alpha`` overrides
            the default α=1.4 (CRR Art. 274(2)).

    Returns:
        Input LazyFrame with four new columns:

        - ``pfe_multiplier: Float64`` — Art. 278(3) multiplier.
        - ``pfe_addon: Float64``      — Art. 278(1) PFE.
        - ``rc_unmargined: Float64``  — Art. 275(1) replacement cost.
        - ``ead_ccr: Float64``        — Art. 274(2) EAD at α = 1.4.

    Per-row α (CRR Art. 274(2) second sub-paragraph): when the caller supplies
    a per-netting-set ``alpha_applied`` column (the SA-CCR adapter sets it to
    1.0 for non-financial / pension-scheme counterparties and 1.4 otherwise),
    the EAD step honours it per row. When the column is absent the scalar
    ``config.alpha`` / 1.4 is used for every row — this keeps the default path
    backward-compatible with callers that do not supply ``alpha_applied``.

    References:
        CRR Art. 274(2); CRR Art. 275(1); CRR Art. 278(1)-(3);
        BCBS CRE52.20-23.
    """
    alpha_value = float(config.alpha) if config is not None else 1.4
    # CRR Art. 274(2) second sub-paragraph: prefer the per-row carve-out scalar
    # (``alpha_applied``) when the caller has joined it onto the frame; fall back
    # to the scalar α for backward compatibility.
    has_alpha_col = "alpha_applied" in netting_sets.collect_schema().names()
    alpha_expr = pl.col("alpha_applied") if has_alpha_col else pl.lit(alpha_value)
    floor_f = _PFE_MULTIPLIER_FLOOR_F
    denom_coeff = _PFE_AGGREGATE_DENOM_COEFF
    one_minus_f = 1.0 - floor_f

    v_minus_c = pl.col("v_net") - pl.col("c_net")
    denom = denom_coeff * one_minus_f * pl.col("addon_aggregate")
    uncapped = floor_f + one_minus_f * (v_minus_c / denom).exp()

    # Delegate the unmargined RC derivation to the canonical
    # ``compute_rc_unmargined`` (Art. 275(1)) so the ``rc_unmargined`` column is
    # guaranteed present before the EAD coalesce reads it.
    netting_sets = compute_rc_unmargined(netting_sets)

    # EAD consumes a unified replacement cost. When the caller has already
    # supplied an ``rc`` column (e.g. the SA-CCR adapter coalescing margined
    # RC per Art. 275(2) over unmargined RC per Art. 275(1)) the EAD step
    # honours it; otherwise it falls back to the unmargined RC computed here.
    has_unified_rc = "rc" in netting_sets.collect_schema().names()
    rc_for_ead = pl.col("rc") if has_unified_rc else pl.col("rc_unmargined")

    return (
        netting_sets.with_columns(
            pl.min_horizontal(pl.lit(1.0), uncapped).alias("pfe_multiplier"),
        )
        .with_columns(
            (pl.col("pfe_multiplier") * pl.col("addon_aggregate")).alias("pfe_addon"),
        )
        .with_columns((alpha_expr * (rc_for_ead + pl.col("pfe_addon"))).alias("ead_ccr"))
    )

CRR Art. 279 — Treatment of collateral

compute_adjusted_notional_ir — src/rwa_calc/engine/ccr/adjusted_notional.py:56
@cites("CRR Art. 279")
def compute_adjusted_notional_ir(
    trades: pl.LazyFrame,
    reporting_date: date,
) -> pl.LazyFrame:
    """SA-CCR adjusted notional for interest-rate trades per CRR Art. 279b(1)(a).

    For ``asset_class == "interest_rate"``:

        d = notional * SD(S, E)
        SD(S, E) = (exp(-0.05*S) - exp(-0.05*E)) / 0.05

    where ``S`` is the years-to-start floored at 10 business days
    (10/250 = 0.04y) and ``E`` is the years-to-maturity. FX / credit / equity
    / commodity branches return null (deferred to subsequent batches).

    Args:
        trades: LazyFrame at trade grain with columns ``asset_class``,
            ``notional``, ``start_date``, ``maturity_date``.
        reporting_date: As-of date for the calculation; used to compute the
            year fractions ``S`` (start) and ``E`` (maturity).

    Returns:
        The input LazyFrame with a new ``adjusted_notional: Float64`` column;
        null for non-IR rows.

    References:
        - CRR Art. 279b(1)(a)
        - BCBS CRE52.40 (footnote: 250-business-day year for the start floor)
    """
    rate = _SUPERVISORY_DURATION_RATE
    s_floor = _START_FLOOR_YEARS

    # Calendar-day -> year fraction. 365.25 is the standard SA-CCR convention
    # for year fractions; the 250-business-day year applies only to the
    # 10-BD start-date floor, which is pre-computed into ``s_floor`` above.
    years_to_start = (pl.col("start_date") - pl.lit(reporting_date)).dt.total_days() / 365.25
    years_to_maturity = (pl.col("maturity_date") - pl.lit(reporting_date)).dt.total_days() / 365.25

    # S floored at 10 BD = 10/250 = 0.04y per Art. 279b(1)(a).
    s_floored = pl.max_horizontal(years_to_start, pl.lit(s_floor))

    # SD(S, E) = (exp(-rate*S) - exp(-rate*E)) / rate
    sd = ((-rate * s_floored).exp() - (-rate * years_to_maturity).exp()) / rate
    d = pl.col("notional") * sd

    return trades.with_columns(
        pl.when(pl.col("asset_class") == "interest_rate")
        .then(d)
        .otherwise(pl.lit(None, dtype=pl.Float64))
        .alias("adjusted_notional")
    )
compute_adjusted_notional_fx — src/rwa_calc/engine/ccr/adjusted_notional.py:110
@cites("CRR Art. 279")
def compute_adjusted_notional_fx(
    trades: pl.LazyFrame,
    base_currency: str,
    fx_rates: pl.LazyFrame,
) -> pl.LazyFrame:
    """SA-CCR adjusted notional for FX trades per CRR Art. 279b(1)(b).

    For ``asset_class == "fx"``:

    - If at least one leg is in the reporting (base) currency
      (Art. 279b(1)(b)(i)): adjusted_notional = the *other* leg's notional
      converted to the base currency at spot.
    - If both legs are in non-base currencies (Art. 279b(1)(b)(ii)):
      adjusted_notional = max(|notional_leg1|, |notional_leg2|) after each
      leg is converted to the base currency at spot.

    Direction lives on ``is_long`` / ``delta``; the adjusted-notional value
    itself is taken in absolute terms per the regulatory comparison rule.

    FX rates are sourced from ``FX_RATES_SCHEMA`` rows where
    ``currency_to == base_currency``; an identity row
    ``{currency_from: base_currency, rate: 1.0}`` is added so a leg already
    in the base currency converts trivially. Rows where a required rate is
    missing produce a null ``adjusted_notional`` — the orchestrator is
    responsible for surfacing the CCR data-quality error.

    Args:
        trades: LazyFrame at trade grain with columns ``asset_class``,
            ``notional``, ``currency``, ``notional_leg2``, ``currency_leg2``.
        base_currency: ISO-4217 reporting currency (e.g. ``"GBP"``) — typically
            ``CalculationConfig.base_currency``.
        fx_rates: LazyFrame conforming to ``FX_RATES_SCHEMA`` with columns
            ``currency_from``, ``currency_to``, ``rate``.

    Returns:
        The input LazyFrame with a new ``adjusted_notional: Float64`` column
        populated for ``asset_class == "fx"`` rows only; null elsewhere.

    References:
        - CRR Art. 279b(1)(b)(i): one-leg-is-base case.
        - CRR Art. 279b(1)(b)(ii): both-legs-foreign max-of-converted case.
    """
    # Build the leg-currency -> base-currency lookup with an identity row so
    # legs already in the base currency convert at 1.0.
    fx_to_base = fx_rates.filter(pl.col("currency_to") == pl.lit(base_currency)).select(
        pl.col("currency_from"),
        pl.col("rate").alias("rate_to_base"),
    )
    identity = pl.LazyFrame(
        {"currency_from": [base_currency], "rate_to_base": [1.0]},
        schema={"currency_from": pl.String, "rate_to_base": pl.Float64},
    )
    rate_lookup = pl.concat([fx_to_base, identity], how="vertical_relaxed")

    # Join twice — once for each leg currency. Use left-joins so missing rates
    # propagate as nulls (the orchestrator emits the CCR error downstream).
    enriched = trades.join(
        rate_lookup.rename({"rate_to_base": "_rate_leg1"}),
        left_on="currency",
        right_on="currency_from",
        how="left",
    ).join(
        rate_lookup.rename({"rate_to_base": "_rate_leg2"}),
        left_on="currency_leg2",
        right_on="currency_from",
        how="left",
    )

    # Converted absolute notionals per leg.
    abs_leg1 = pl.col("notional").abs() * pl.col("_rate_leg1")
    abs_leg2 = pl.col("notional_leg2").abs() * pl.col("_rate_leg2")

    one_leg_is_base = (pl.col("currency") == pl.lit(base_currency)) | (
        pl.col("currency_leg2") == pl.lit(base_currency)
    )

    # Art. 279b(1)(b)(i): when one leg is the base currency, take the *other*
    # leg converted (which equals its absolute notional × spot). When leg1 is
    # the base, take abs_leg2; when leg2 is the base, take abs_leg1.
    one_leg_value = (
        pl.when(pl.col("currency") == pl.lit(base_currency)).then(abs_leg2).otherwise(abs_leg1)
    )

    # Art. 279b(1)(b)(ii): both legs foreign — take max of converted notionals.
    both_foreign_value = pl.max_horizontal(abs_leg1, abs_leg2)

    fx_adjusted = pl.when(one_leg_is_base).then(one_leg_value).otherwise(both_foreign_value)

    # Gate on asset_class == "fx"; preserve any existing adjusted_notional from
    # the IR branch via coalesce — callers may have run the IR branch first.
    out = enriched.with_columns(
        pl.when(pl.col("asset_class") == "fx")
        .then(fx_adjusted)
        .otherwise(pl.lit(None, dtype=pl.Float64))
        .alias("_fx_adjusted_notional")
    )

    # If the input already has an adjusted_notional column (e.g. from the IR
    # branch), preserve non-null values and overlay FX where applicable.
    if "adjusted_notional" in trades.collect_schema().names():
        out = out.with_columns(
            pl.coalesce(pl.col("adjusted_notional"), pl.col("_fx_adjusted_notional")).alias(
                "adjusted_notional"
            )
        )
    else:
        out = out.rename({"_fx_adjusted_notional": "adjusted_notional"})

    return out.drop("_rate_leg1", "_rate_leg2", strict=False).drop(
        "_fx_adjusted_notional", strict=False
    )
compute_adjusted_notional_credit — src/rwa_calc/engine/ccr/adjusted_notional.py:224
@cites("CRR Art. 279")
def compute_adjusted_notional_credit(
    trades: pl.LazyFrame,
    reporting_date: date,
) -> pl.LazyFrame:
    """SA-CCR adjusted notional for credit derivatives per CRR Art. 279b(1)(a).

    For ``asset_class == "credit"``:

        d = notional * SD(S, E)
        SD(S, E) = (exp(-0.05*S) - exp(-0.05*E)) / 0.05

    where ``S`` is the years-to-start floored at 10 business days
    (10/250 = 0.04y) and ``E`` is the years-to-maturity. The supervisory-
    duration kernel is shared with the interest-rate asset class — Art. 279b(1)(a)
    covers both. Coalesce-safe with the IR / FX branches when run in sequence:
    the credit branch only overlays rows where ``asset_class == "credit"``.

    Args:
        trades: LazyFrame at trade grain with columns ``asset_class``,
            ``notional``, ``start_date``, ``maturity_date``.
        reporting_date: As-of date for the calculation; used to compute the
            year fractions ``S`` (start) and ``E`` (maturity).

    Returns:
        The input LazyFrame with a new (or coalesced) ``adjusted_notional: Float64``
        column. Non-credit rows preserve any existing value from a prior branch
        (IR / FX) or remain null.

    References:
        - CRR Art. 279b(1)(a)
        - BCBS CRE52.41-43 (supervisory duration shared with IR)
    """
    rate = _SUPERVISORY_DURATION_RATE
    s_floor = _START_FLOOR_YEARS

    # Calendar-day -> year fraction. 365.25 is the standard SA-CCR convention
    # for year fractions; the 250-business-day year applies only to the
    # 10-BD start-date floor, which is pre-computed into ``s_floor`` above.
    years_to_start = (pl.col("start_date") - pl.lit(reporting_date)).dt.total_days() / 365.25
    years_to_maturity = (pl.col("maturity_date") - pl.lit(reporting_date)).dt.total_days() / 365.25

    # S floored at 10 BD = 10/250 = 0.04y per Art. 279b(1)(a).
    s_floored = pl.max_horizontal(years_to_start, pl.lit(s_floor))

    # SD(S, E) = (exp(-rate*S) - exp(-rate*E)) / rate
    sd = ((-rate * s_floored).exp() - (-rate * years_to_maturity).exp()) / rate
    d = pl.col("notional") * sd

    credit_adjusted = (
        pl.when(pl.col("asset_class") == "credit").then(d).otherwise(pl.lit(None, dtype=pl.Float64))
    )

    # Preserve any existing adjusted_notional column from upstream IR / FX
    # branches via coalesce; otherwise emit a fresh column.
    if "adjusted_notional" in trades.collect_schema().names():
        return trades.with_columns(
            pl.coalesce(pl.col("adjusted_notional"), credit_adjusted).alias("adjusted_notional")
        )
    return trades.with_columns(credit_adjusted.alias("adjusted_notional"))
compute_adjusted_notional_equity — src/rwa_calc/engine/ccr/adjusted_notional.py:286
@cites("CRR Art. 279")
def compute_adjusted_notional_equity(trades: pl.LazyFrame) -> pl.LazyFrame:
    """SA-CCR adjusted notional for equity trades per CRR Art. 279b(1)(c).

    For ``asset_class == "equity"``:

        d = abs(market_price * number_of_units)

    Direction lives on ``is_long`` / ``supervisory_delta``; the adjusted-notional
    value itself is taken in absolute terms per the regulatory rule. Null
    ``market_price`` or null ``number_of_units`` propagate as null
    ``adjusted_notional`` — the orchestrator surfaces the CCR data-quality
    error at the pipeline-adapter boundary.

    When the input frame already carries an ``adjusted_notional`` column from a
    prior IR / FX branch, this function coalesces — non-null upstream values
    are preserved and the equity result only overlays where the upstream value
    is null (equity rows).

    Args:
        trades: LazyFrame at trade grain with columns ``asset_class``,
            ``market_price`` and ``number_of_units``.

    Returns:
        The input LazyFrame with an ``adjusted_notional: Float64`` column
        populated for ``asset_class == "equity"`` rows; existing non-null
        values from upstream branches are preserved.

    References:
        - CRR Art. 279b(1)(c): equity adjusted notional d = market_price × units.
    """
    equity_adjusted = (pl.col("market_price") * pl.col("number_of_units")).abs()

    out = trades.with_columns(
        pl.when(pl.col("asset_class") == "equity")
        .then(equity_adjusted)
        .otherwise(pl.lit(None, dtype=pl.Float64))
        .alias("_eq_adjusted_notional")
    )

    if "adjusted_notional" in trades.collect_schema().names():
        out = out.with_columns(
            pl.coalesce(pl.col("adjusted_notional"), pl.col("_eq_adjusted_notional")).alias(
                "adjusted_notional"
            )
        )
    else:
        out = out.rename({"_eq_adjusted_notional": "adjusted_notional"})

    return out.drop("_eq_adjusted_notional", strict=False)
compute_adjusted_notional_commodity — src/rwa_calc/engine/ccr/adjusted_notional.py:338
@cites("CRR Art. 279")
def compute_adjusted_notional_commodity(trades: pl.LazyFrame) -> pl.LazyFrame:
    """SA-CCR adjusted notional for commodity trades per CRR Art. 279b(1)(c).

    For ``asset_class == "commodity"``:

        d = market_price × number_of_units

    The product is in the trade currency; no FX conversion is required because
    ``market_price`` is already denominated in the same currency as the trade.
    Direction lives on ``is_long`` / ``delta`` — the adjusted-notional value
    itself is the unsigned product per Art. 279b(1)(c).

    Coalesce-safe overlay: when the input already carries an
    ``adjusted_notional`` column from a prior IR / FX / credit / equity branch,
    non-null values on non-commodity rows are preserved.

    Args:
        trades: LazyFrame at trade grain with columns ``asset_class``,
            ``market_price`` and ``number_of_units``.

    Returns:
        The input LazyFrame with a (possibly overlaid) ``adjusted_notional:
        Float64`` column populated for ``asset_class == "commodity"`` rows
        only; null on non-commodity rows when no prior branch populated them.

    References:
        - CRR Art. 279b(1)(c)
        - BCBS CRE52.46-48
    """
    co_adjusted = pl.col("market_price") * pl.col("number_of_units")

    out = trades.with_columns(
        pl.when(pl.col("asset_class") == "commodity")
        .then(co_adjusted)
        .otherwise(pl.lit(None, dtype=pl.Float64))
        .alias("_co_adjusted_notional")
    )

    if "adjusted_notional" in trades.collect_schema().names():
        out = out.with_columns(
            pl.coalesce(pl.col("adjusted_notional"), pl.col("_co_adjusted_notional")).alias(
                "adjusted_notional"
            )
        )
    else:
        out = out.rename({"_co_adjusted_notional": "adjusted_notional"})

    return out.drop("_co_adjusted_notional", strict=False)
compute_maturity_factor_unmargined — src/rwa_calc/engine/ccr/maturity_factor.py:65
@cites("CRR Art. 279")
def compute_maturity_factor_unmargined(trades: pl.LazyFrame) -> pl.LazyFrame:
    """Maturity factor for unmargined transactions per CRR Art. 279c(1).

    ``MF = sqrt(min(M, 1y) / 1y)`` measured on the 250-business-day-year basis,
    with the residual maturity floored at 10 business days:

        MF = sqrt(min(max(BD, 10), 250) / 250)

    where ``BD`` is the residual maturity in *business days* from the reporting
    date to the trade's maturity date (``business_days_to_maturity``, built by
    the SA-CCR adapter via ``pl.business_day_count``). CRR Art. 279c expresses
    both the unmargined and margined maturity factors against the same "1 year"
    denominator; because the margined branch's MPOR is a business-day count
    divided by 250, "1 year" = 250 business days throughout — so the unmargined
    residual maturity is measured in business days too. A trade with ≥ 250 BD
    (≈ 1 calendar year) to maturity collapses to MF = 1.0; the 10-BD floor
    (BCBS CRE52.47-52.48 fn.13) means a trade with < 10 BD to maturity never
    drops below ``sqrt(10/250) = 0.20``.

    The 10-BD floor here is on the residual maturity ``M`` and is distinct from
    (a) the Art. 279b 10-BD floor on the *start date* ``S`` in the supervisory
    duration (``engine/ccr/adjusted_notional.py``) and (b) the Art. 285 margined
    MPOR floors — same numeric value, different provisions on different quantities.

    Note: the IR maturity-bucket thresholds (Art. 277(2): 1y / 5y) are a
    separate, calendar-based partition handled by ``assign_ir_maturity_bucket``
    and are NOT affected by this business-day measure.

    Args:
        trades: LazyFrame containing a ``business_days_to_maturity`` column
            (integer) — residual maturity in business days from the reporting
            date to the trade's maturity date.

    Returns:
        The input LazyFrame with a new ``maturity_factor: Float64`` column.

    References:
        CRR Art. 279c(1); BCBS CRE52.47-52.48 (+ fn.13 10-BD M floor), 52.50-52.
    """
    bd_per_year = _SA_CCR_BUSINESS_DAYS_PER_YEAR
    floor_days = _MF_UNMARGINED_FLOOR_DAYS
    return trades.with_columns(
        (
            pl.min_horizontal(
                pl.max_horizontal(
                    pl.col("business_days_to_maturity"),
                    pl.lit(floor_days),
                ),
                pl.lit(bd_per_year),
            ).cast(pl.Float64)
            / float(bd_per_year)
        )
        .sqrt()
        .alias("maturity_factor")
    )
compute_maturity_factor_margined — src/rwa_calc/engine/ccr/maturity_factor.py:126
@cites("CRR Art. 279")
def compute_maturity_factor_margined(trades: pl.LazyFrame) -> pl.LazyFrame:
    """Maturity factor for margined transactions per CRR Art. 279c(2).

    ``MF = (3/2) * sqrt(MPOR_eff / 250)``

    ``MPOR_eff`` is derived per the CRR Art. 285 cascade:

    1. Base MPOR (Art. 285(2)): 10 BD (OTC derivative netting set,
       Art. 285(2)(b)). The Art. 285(2)(a) 5-BD SFT/repo/margin-lending base is
       NOT modelled here: this function is derivatives-only since the SFT/FCCM
       separation (SFTs are priced by the FCCM ``sft_fccm`` stage from
       ``RawDataBundle.sft`` and never enter the SA-CCR chain), so every netting
       set reaching this function is an OTC derivative netting set.
    2. Upgrade to 20 BD (Art. 285(3)) when either:
        - ``number_of_trades > 5000`` (Art. 285(3)(a)), or
        - ``has_illiquid_collateral_or_hard_to_replace_otc`` is True
          (Art. 285(3)(b))
    3. Dispute doubling (Art. 285(4)): if ``dispute_count_qtr > 2``, double
       the resulting MPOR base.
    4. Remargining-frequency adjustment (Art. 285(5)):
       ``MPOR_eff = base + remargining_frequency_days - 1``.
    5. Input-MPOR floor: ``MPOR_eff = max(MPOR_eff, mpor_days_input)``.

    Args:
        trades: LazyFrame with one row per trade carrying the Art. 285 cascade
            inputs as columns:

            - ``netting_set_id``                — group key
            - ``number_of_trades``              — count of trades in the NS
            - ``has_illiquid`` — bool flag (aliased from the netting-set
              column ``has_illiquid_collateral_or_hard_to_replace_otc`` at
              the join site)
            - ``dispute_count_qtr``             — disputes in the prior quarter
            - ``remargining_frequency_days``    — CSA remargining frequency
            - ``mpor_days_input``               — firm-supplied MPOR floor (BD)

    Returns:
        The input LazyFrame with two new Float64 columns:

        - ``maturity_factor_margined`` — the gated margined MF (null on
          unmargined rows so the pipeline-adapter coalesce can fall back to
          the unmargined MF without clobbering it).
        - ``maturity_factor`` — an alias of ``maturity_factor_margined``
          retained for the P8.14 unit tests, which feed an all-margined
          denormalised frame and read the bare column.

    References:
        CRR Art. 279c(2); CRR Art. 285(2)-(5); BCBS CRE52.51-52.
    """
    # Step 1 — base MPOR per Art. 285(2)(b): 10 BD for the OTC derivative
    # netting set. The Art. 285(2)(a) 5-BD SFT/repo base is not modelled here:
    # this function is derivatives-only since the SFT/FCCM separation, so every
    # netting set reaching it is an OTC derivative netting set (SFTs are priced
    # by the FCCM ``sft_fccm`` stage and never enter the SA-CCR chain).
    base_post_step1 = pl.lit(_MF_FLOOR_DAYS_OTC)

    # Step 2 — upgrade to 20 BD when the netting set is large
    # (Art. 285(3)(a)) or contains illiquid collateral / hard-to-replace
    # OTC trades (Art. 285(3)(b)).
    is_large_or_illiquid = pl.col("number_of_trades") > pl.lit(_MF_LARGE_NETTING_SET_TRADE_COUNT)
    is_large_or_illiquid = is_large_or_illiquid | pl.col("has_illiquid")

    base_post_step2 = (
        pl.when(is_large_or_illiquid)
        .then(pl.lit(_MF_FLOOR_DAYS_LARGE_OR_ILLIQUID))
        .otherwise(base_post_step1)
    )

    # Step 3 — dispute doubling per Art. 285(4): when dispute_count_qtr
    # exceeds the regulatory threshold (more than two), the MPOR base
    # is doubled.
    base_post_step3 = (
        pl.when(pl.col("dispute_count_qtr") > pl.lit(_MF_DISPUTE_THRESHOLD))
        .then(base_post_step2 * pl.lit(_MF_DISPUTE_MULTIPLIER))
        .otherwise(base_post_step2)
    )

    # Step 4 — remargining frequency adjustment per Art. 285(5):
    # MPOR_eff = base + remargining_frequency_days − 1.
    mpor_eff_pre_floor = base_post_step3 + pl.col("remargining_frequency_days") - pl.lit(1)

    # Step 5 — input-MPOR floor: MPOR_eff = max(MPOR_eff, mpor_days_input).
    # Null-safety: a null ``mpor_days_input`` would null the whole MF through
    # ``max_horizontal``; fall back to the Art. 285(2)(b) 10-BD OTC floor so a
    # missing firm-supplied MPOR never silently drops the margined MF to null.
    mpor_eff = pl.max_horizontal(
        mpor_eff_pre_floor, pl.col("mpor_days_input").fill_null(_MF_FLOOR_DAYS_OTC)
    )

    # MF = 1.5 * sqrt(MPOR_eff / 250) per Art. 279c(2).
    maturity_factor = (
        pl.lit(_MF_MARGINED_SCALAR)
        * (mpor_eff.cast(pl.Float64) / pl.lit(float(_SA_CCR_BUSINESS_DAYS_PER_YEAR))).sqrt()
    ).cast(pl.Float64)

    # Gate on ``is_margined`` (mirrors ``compute_rc_margined``): emit the MF only
    # for margined rows; unmargined rows get null so the pipeline-adapter
    # coalesce falls back to ``maturity_factor_unmargined``. A null/absent
    # ``is_margined`` flows to the ``.otherwise`` (null) branch exactly like an
    # explicit False — the conservative NETTING_SET_SCHEMA default — so no
    # ``fill_null`` is needed on the gate. ``maturity_factor`` is written as an
    # alias of the gated margined column for the P8.14 unit tests (all-margined
    # frame).
    maturity_factor_margined = (
        pl.when(pl.col("is_margined"))
        .then(maturity_factor)
        .otherwise(pl.lit(None, dtype=pl.Float64))
    )

    return trades.with_columns(
        maturity_factor_margined.alias("maturity_factor_margined"),
        maturity_factor_margined.alias("maturity_factor"),
    )

CRR Art. 279a — Supervisory delta

compute_supervisory_delta_linear — src/rwa_calc/engine/ccr/supervisory_delta.py:64
@cites("CRR Art. 279a")
def compute_supervisory_delta_linear(trades: pl.LazyFrame) -> pl.LazyFrame:
    """Supervisory delta for non-option directional trades per CRR Art. 279a(1).

    delta = +1 for long positions in the primary risk driver
    delta = -1 for short positions in the primary risk driver

    The European-option Black-Scholes Phi(d1) branch (rows where
    ``option_strike`` is not null) and the CDO-tranche formula are handled by
    :func:`compute_supervisory_delta_option` and
    :func:`compute_supervisory_delta_cdo_tranche` respectively.

    Args:
        trades: LazyFrame containing an ``is_long`` Boolean column.

    Returns:
        The input LazyFrame with a new ``supervisory_delta: Float64`` column.

    References:
        CRR Art. 279a(1); BCBS CRE52.41-43.
    """
    return trades.with_columns(
        pl.when(pl.col("is_long")).then(1.0).otherwise(-1.0).alias("supervisory_delta")
    )
compute_supervisory_delta_option — src/rwa_calc/engine/ccr/supervisory_delta.py:90
@cites("CRR Art. 279a")
def compute_supervisory_delta_option(trades: pl.LazyFrame) -> pl.LazyFrame:
    """Supervisory delta for European options per CRR Art. 279a(2).

    For rows that carry ``option_strike`` AND ``option_underlying_price``,
    apply the Black-Scholes Phi(d1) formula:

        d1 = (ln(P/K) + 0.5 * sigma^2 * T) / (sigma * sqrt(T))

        long  call:  delta = +Phi(d1)
        short call:  delta = -Phi(d1)
        long  put:   delta = -Phi(-d1)
        short put:   delta = +Phi(-d1)

    where:
        P  = ``option_underlying_price``
        K  = ``option_strike``
        T  = (maturity_date - start_date).days / 365  (calendar-day basis)
        sigma = supervisory option volatility from
            ``SA_CCR_OPTION_VOLATILITY_*`` keyed off ``asset_class``.

    Rows where ``option_strike`` is null fall back to the linear +/- 1 delta
    per Art. 279a(1), preserving the behaviour of
    :func:`compute_supervisory_delta_linear`.

    Args:
        trades: LazyFrame with ``is_long``, ``asset_class``, ``option_type``,
            ``option_strike``, ``option_underlying_price``, ``start_date``,
            and ``maturity_date`` columns.

    Returns:
        The input LazyFrame with a new ``supervisory_delta: Float64`` column.

    References:
        CRR Art. 279a(2); BCBS CRE52.42; BCBS CRE52.47 (supervisory volatility).
    """
    # Asset-class -> sigma lookup as a small in-memory frame for join.
    sigma_lookup = pl.LazyFrame(
        {
            "asset_class": list(_OPTION_VOLATILITY_BY_ASSET_CLASS.keys()),
            "_option_sigma": list(_OPTION_VOLATILITY_BY_ASSET_CLASS.values()),
        },
        schema={"asset_class": pl.Utf8, "_option_sigma": pl.Float64},
    )

    is_option = (
        pl.col("option_strike").is_not_null() & pl.col("option_underlying_price").is_not_null()
    )

    # T = calendar days / 365 between start_date and maturity_date. The fixture
    # encodes T via maturity = start + round(T_nominal * 365) so this recovers
    # T_nominal exactly when reporting_date == start_date.
    t_years = (pl.col("maturity_date") - pl.col("start_date")).dt.total_days().cast(
        pl.Float64
    ) / 365.0

    sigma = pl.col("_option_sigma")
    p = pl.col("option_underlying_price")
    k = pl.col("option_strike")

    d1 = ((p / k).log() + 0.5 * sigma * sigma * t_years) / (sigma * t_years.sqrt())

    phi_d1 = normal_cdf(d1)
    phi_neg_d1 = normal_cdf(-d1)

    is_call = pl.col("option_type") == "call"
    is_long = pl.col("is_long")

    # Sign rule per CRR Art. 279a(2):
    #   long  call -> +Phi(d1)
    #   short call -> -Phi(d1)
    #   long  put  -> -Phi(-d1)
    #   short put  -> +Phi(-d1)
    option_delta = (
        pl.when(is_call & is_long)
        .then(phi_d1)
        .when(is_call & ~is_long)
        .then(-phi_d1)
        .when(~is_call & is_long)
        .then(-phi_neg_d1)
        .otherwise(phi_neg_d1)
    )

    linear_delta = pl.when(is_long).then(1.0).otherwise(-1.0)

    return (
        trades.join(sigma_lookup, on="asset_class", how="left")
        .with_columns(
            pl.when(is_option)
            .then(option_delta)
            .otherwise(linear_delta)
            .cast(pl.Float64)
            .alias("supervisory_delta")
        )
        .drop("_option_sigma")
    )
compute_supervisory_delta_cdo_tranche — src/rwa_calc/engine/ccr/supervisory_delta.py:188
@cites("CRR Art. 279a")
def compute_supervisory_delta_cdo_tranche(trades: pl.LazyFrame) -> pl.LazyFrame:
    """Supervisory delta for CDO tranches per CRR Art. 279a(3).

    For rows that carry ``cdo_attachment`` AND ``cdo_detachment``, apply the
    closed-form:

        |delta| = 15 / ((1 + 14 * A) * (1 + 14 * D))

    with sign +1 for long tranches and -1 for short tranches.

    Rows where ``cdo_attachment`` is null fall back to the linear +/- 1 delta
    per Art. 279a(1).

    Args:
        trades: LazyFrame with ``is_long``, ``cdo_attachment``, and
            ``cdo_detachment`` columns.

    Returns:
        The input LazyFrame with a new ``supervisory_delta: Float64`` column.

    References:
        CRR Art. 279a(3); BCBS CRE52.43.
    """
    is_cdo = pl.col("cdo_attachment").is_not_null() & pl.col("cdo_detachment").is_not_null()

    a = pl.col("cdo_attachment")
    d = pl.col("cdo_detachment")

    numerator = _CDO_TRANCHE_NUMERATOR
    coefficient = _CDO_TRANCHE_COEFFICIENT

    magnitude = numerator / ((1.0 + coefficient * a) * (1.0 + coefficient * d))

    cdo_delta = pl.when(pl.col("is_long")).then(magnitude).otherwise(-magnitude)
    linear_delta = pl.when(pl.col("is_long")).then(1.0).otherwise(-1.0)

    return trades.with_columns(
        pl.when(is_cdo)
        .then(cdo_delta)
        .otherwise(linear_delta)
        .cast(pl.Float64)
        .alias("supervisory_delta")
    )

CRR Art. 285 — Exposure value for netting sets subject to a margin agreement

sft_bundle_to_exposures — src/rwa_calc/engine/sft/fccm.py:114
@cites("CRR Art. 220")
@cites("CRR Art. 223")
@cites("CRR Art. 224")
@cites("CRR Art. 226")
@cites("CRR Art. 271")
@cites("CRR Art. 285")
def sft_bundle_to_exposures(
    raw_sft: RawSFTBundle,
    reporting_date: date,
    rulepack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """Shape FCCM SFT EADs into synthetic exposure rows from the lean SFT bundle.

    The sole FCCM entry point (SFT/FCCM separation): consumes the dedicated
    :class:`RawSFTBundle` (``RawDataBundle.sft``). The SFT/derivative
    discrimination lives in the *input bundle* now, not in any in-engine
    ``transaction_type`` split:

    - Every trade row is an SFT (no ``transaction_type`` filter): the whole
      ``raw_sft.trades`` frame is in scope.
    - The netting-set ``counterparty_reference`` is denormalised onto the trade
      row (FCCM scope is single-trade single-counterparty netting sets,
      Art. 220(1)(a)), so the NS-grain counterparty frame is derived from the
      trades themselves rather than a separate netting-set table.
    - Collateral is OPTIONAL (``raw_sft.collateral is None`` for an
      uncollateralised SFT, the common case): a missing collateral leaf yields a
      zero collateral term (CVA·(1−HC−HFX) = 0), exactly as an empty
      ``ccr_collateral`` frame would.

    Each emitted synthetic exposure row carries the FCCM provenance:
    ``exposure_reference = "ccr__<netting_set_id>"``, ``risk_type = "CCR_SFT"``,
    ``ccr_method = "fccm_sft"``, ``drawn_amount = E*``, ``ead_ccr = E*``.

    Args:
        raw_sft: The SFT (FCCM) input bundle — every trade row is an SFT with the
            denormalised netting-set counterparty; collateral optional.
        reporting_date: As-of date; written to ``value_date``.
        rulepack: The resolved RUN rulepack supplying the Art. 162 effective-
            maturity floors / regime gate for the ``ccr_effective_maturity``
            carrier. ``None`` (the back-compat default used by direct unit /
            acceptance calls) falls back to the module-level CRR ``_PACK``; the
            stage adapter threads the run pack so production runs are regime-
            correct.

    Returns:
        LazyFrame at netting-set grain. Empty (zero-row) frame when the trades
        bundle is empty.

    References:
        CRR Art. 271(2); Art. 220(1)(a); Art. 223(5); Art. 224 Table 1;
        Art. 224(2)(b); Art. 226; Art. 285(2)-(5).
    """
    sft_trades_lf = raw_sft.trades.sft_trades
    # Counterparty is denormalised onto the trade — collapse to NS grain. The
    # ``first()`` aggregation is exact under the single-CP-per-NS scope
    # (Art. 220(1)(a)); should a future netting set span counterparties the
    # FCCM scope itself would need revisiting.
    ns_counterparty_lf = sft_trades_lf.group_by("netting_set_id").agg(
        pl.col("counterparty_reference").first()
    )
    ccr_collateral_lf = (
        raw_sft.collateral.sft_collateral if raw_sft.collateral is not None else None
    )
    return _build_sft_exposure_rows(
        sft_trades_lf=sft_trades_lf,
        ns_counterparty_lf=ns_counterparty_lf,
        ccr_collateral_lf=ccr_collateral_lf,
        reporting_date=reporting_date,
        pack=rulepack if rulepack is not None else _PACK,
    )
_derive_margining_terms — src/rwa_calc/engine/sft/fccm.py:186
@cites("CRR Art. 285")
def _derive_margining_terms(
    is_margined: bool | None,
    remargining_frequency_days: int | None,
    mpor_floor_category: str | None,
    has_margin_dispute_doubling: bool | None,
    mpor_days_override: int | None,
) -> tuple[int, int]:
    """Return ``(T_M, non_daily_N_R)`` for one SFT netting set.

    Selects the applied-haircut holding period T_M and whether the Art. 226
    non-daily revaluation factor √((N_R+T_M−1)/T_M) applies:

    - Branch (a) unmargined: ``(5-BD repo period, real N_R)`` → the Art. 226
      factor applies (driven by ``remargining_frequency_days``; collapses to
      1.0 at daily revaluation). T_M per Art. 224(2)(b).
    - Branch (b) margined: ``(MPOR, 1)`` → the Art. 226 factor is suppressed
      (N_R=1), because the MPOR already encodes the remargin period N.
      MPOR = ``mpor_days_override`` when supplied, else F·mult + N − 1 where F is
      the Art. 285(2)-(3) floor by category and mult = the Art. 285(4) doubling
      multiplier (2) when a margin dispute applies.

    All F values and the dispute multiplier are read from cited pack scalars at
    module load — no regulatory numerics here. Single-trade-per-NS scope is
    assumed (Art. 220(1)(a)); the caller derives terms per trade.

    Args:
        is_margined: True selects the margined branch (b); False/None → (a).
        remargining_frequency_days: N (branch a: N_R; branch b: N). Default 1.
        mpor_floor_category: F selector ('repo_only'/'other'/'illiquid_or_large').
        has_margin_dispute_doubling: True doubles F (Art. 285(4)).
        mpor_days_override: Explicit MPOR (business days); supersedes derivation.

    Returns:
        ``(T_M, non_daily_N_R)`` — both ints in business days / count.
    """
    n = int(remargining_frequency_days) if remargining_frequency_days is not None else 1
    if not is_margined:
        return (_LIQUIDATION_PERIOD_REPO, n)
    if mpor_days_override is not None:
        return (int(mpor_days_override), 1)
    floor = _MPOR_FLOOR_BY_CATEGORY[mpor_floor_category or "repo_only"]
    mult = _MPOR_DISPUTE_MULT if has_margin_dispute_doubling else 1
    return (floor * mult + n - 1, 1)

CRR Art. 291 — Wrong-Way Risk

apply_wwr_gate — src/rwa_calc/engine/ccr/wwr.py:93
@cites("CRR Art. 291")
def apply_wwr_gate(raw_ccr: RawCCRBundle) -> RawCCRBundle:
    """Partition netting sets to isolate specific-WWR trades; tag general WWR.

    Implements CRR Art. 291(4)-(5):

    - **Specific WWR** (Art. 291(1)(b) / 291(5)(a)/(c)): every trade with
      ``is_specific_wwr=True`` is broken out into its own single-trade
      synthetic netting set whose id is
      ``<original_ns_id>__wwr__<trade_id>``. The synthetic NS inherits all
      attributes from the original and additionally carries
      ``wwr_lgd_override = 1.0`` so downstream IRB consumption applies
      LGD = 100% (Art. 291(5)(c)). A residual NS keyed by the original
      ``netting_set_id`` retains the non-WWR trades with
      ``wwr_lgd_override = null``.
    - **General WWR** (Art. 291(1)(a) / 291(6)): netting sets with
      ``has_general_wwr_flag=True`` are not partitioned but emit a
      diagnostic CCR011 WARNING.

    Pipeline position:
        apply_legal_enforceability_gate -> apply_wwr_gate -> CCR calculators

    Args:
        raw_ccr: Aggregate CCR input bundle.

    Returns:
        A new ``RawCCRBundle`` (frozen dataclass) with:

        - ``trades`` remapped: each specific-WWR trade carries its new
          synthetic ``netting_set_id``.
        - ``netting_sets`` partitioned: each affected original NS is
          replaced by (1) a residual row (non-WWR trades, override null)
          plus (2) one synthetic row per WWR trade (override = 1.0).
        - ``errors`` extended with one CCR010 WARNING per original NS
          containing >=1 WWR trade, plus one CCR011 WARNING per NS with
          ``has_general_wwr_flag=True``.

        Netting sets with no WWR trades and ``has_general_wwr_flag=False``
        pass through unchanged.

    References:
        CRR Art. 291(1)(a)/(1)(b)/(4)/(5)(a)/(5)(c)/(6).
    """
    # Backfill the schema-declared WWR columns when the loader/fixture has
    # not yet populated them. ``ensure_columns`` is a no-op when the columns
    # are already present.
    netting_sets_lf = ensure_columns(raw_ccr.netting_sets.netting_sets, _WWR_NS_DEFAULTS)
    trades_lf = ensure_columns(raw_ccr.trades.trades, _WWR_TRADE_DEFAULTS)

    # Materialise the small NS and trade frames to drive partition logic.
    # Netting-set and trade frames are at firm scale (hundreds to low
    # thousands of rows), so collecting is acceptable — mirrors the
    # apply_legal_enforceability_gate precedent.
    netting_sets_df = netting_sets_lf.collect()
    trades_df = trades_lf.collect()

    new_errors: list[CalculationError] = list(raw_ccr.errors)

    # --- General WWR (Art. 291(1)(a), 291(6)): diagnostic only --------------
    general_wwr_mask = netting_sets_df["has_general_wwr_flag"].fill_null(False)
    general_wwr_rows = netting_sets_df.filter(general_wwr_mask)
    for ns_row in general_wwr_rows.iter_rows(named=True):
        new_errors.append(
            CalculationError(
                code=CCR_WWR_GENERAL_ERROR_CODE,
                message=(
                    f"Netting set {ns_row['netting_set_id']} carries "
                    "has_general_wwr_flag=True per Art. 291(1)(a); "
                    "general WWR identified for downstream review."
                ),
                severity=ErrorSeverity.WARNING,
                category=ErrorCategory.CCR_WWR_GENERAL,
                counterparty_reference=ns_row.get("counterparty_reference"),
                regulatory_reference=CCR_WWR_GENERAL_REG_REF,
                field_name="has_general_wwr_flag",
                expected_value="False (no general WWR correlation)",
                actual_value="True",
            )
        )

    # --- Specific WWR (Art. 291(1)(b), 291(5)(a)/(c)): break-out -----------
    wwr_trade_mask = trades_df["is_specific_wwr"].fill_null(False)
    if not wwr_trade_mask.any():
        logger.info("wwr gate: no specific-WWR trades flagged; no break-out applied")
        return dataclasses.replace(raw_ccr, errors=new_errors)

    wwr_trades_df = trades_df.filter(wwr_trade_mask)
    affected_ns_ids = wwr_trades_df["netting_set_id"].unique().to_list()

    # Rewrite the trades frame: each WWR trade gets a synthetic NS id.
    new_trades_lf = trades_lf.with_columns(
        pl.when(pl.col("is_specific_wwr").fill_null(False))
        .then(
            pl.concat_str(
                [pl.col("netting_set_id"), pl.lit(_WWR_NS_ID_SEPARATOR), pl.col("trade_id")]
            )
        )
        .otherwise(pl.col("netting_set_id"))
        .alias("netting_set_id")
    )

    # Build the partitioned netting-set frame. Both halves already carry the
    # ``wwr_lgd_override`` column thanks to the ``ensure_columns`` call above.
    affected_ns_df = netting_sets_df.filter(
        netting_sets_df["netting_set_id"].is_in(affected_ns_ids)
    )
    unaffected_ns_df = netting_sets_df.filter(
        ~netting_sets_df["netting_set_id"].is_in(affected_ns_ids)
    )

    # Residual rows: same NS attributes, override null. Synthetic rows: same
    # attributes plus override = 1.0 and the synthetic id.
    residual_rows_df = affected_ns_df.with_columns(
        pl.lit(None, dtype=pl.Float64).alias("wwr_lgd_override")
    )

    synthetic_rows_df = (
        wwr_trades_df.select(["trade_id", "netting_set_id"])
        .join(affected_ns_df, on="netting_set_id", how="left")
        .with_columns(
            pl.concat_str(
                [pl.col("netting_set_id"), pl.lit(_WWR_NS_ID_SEPARATOR), pl.col("trade_id")]
            ).alias("netting_set_id"),
            pl.lit(_WWR_SPECIFIC_LGD_OVERRIDE).alias("wwr_lgd_override"),
        )
        .drop("trade_id")
        .select(residual_rows_df.columns)
    )

    new_netting_sets_df = pl.concat(
        [unaffected_ns_df, residual_rows_df, synthetic_rows_df],
        how="vertical_relaxed",
    )

    # Emit one CCR010 WARNING per affected original netting set.
    for ns_row in affected_ns_df.iter_rows(named=True):
        ns_id = ns_row["netting_set_id"]
        new_errors.append(
            CalculationError(
                code=CCR_WWR_SPECIFIC_ERROR_CODE,
                message=(
                    f"Netting set {ns_id} contains >=1 trade with "
                    "is_specific_wwr=True per Art. 291(1)(b); each WWR trade "
                    "broken out into its own synthetic netting set with "
                    "LGD = 100% per Art. 291(5)(c)."
                ),
                severity=ErrorSeverity.WARNING,
                category=ErrorCategory.CCR_WWR_SPECIFIC,
                counterparty_reference=ns_row.get("counterparty_reference"),
                regulatory_reference=CCR_WWR_SPECIFIC_REG_REF,
                field_name="is_specific_wwr",
                expected_value="False (no Art. 291(1)(b) legal connection)",
                actual_value="True",
            )
        )

    logger.info(
        "wwr gate broke out %d trade(s) across %d netting set(s) into synthetic single-trade NSes",
        wwr_trades_df.height,
        len(affected_ns_ids),
    )

    return dataclasses.replace(
        raw_ccr,
        trades=TradeBundle(trades=new_trades_lf, errors=list(raw_ccr.trades.errors)),
        netting_sets=NettingSetBundle(
            netting_sets=new_netting_sets_df.lazy(),
            errors=list(raw_ccr.netting_sets.errors),
        ),
        errors=new_errors,
    )

CRR Art. 306 — Own funds requirements for trade exposures

apply_ccp_risk_weight — src/rwa_calc/engine/ccr/ccp.py:51
@cites("CRR Art. 306")
def apply_ccp_risk_weight(
    exposures: pl.LazyFrame,
    counterparties: pl.LazyFrame,
    trades: pl.LazyFrame,
) -> pl.LazyFrame:
    """Annotate ``risk_weight`` for QCCP trade exposures per CRR Art. 306(1).

    The function joins the QCCP flag from ``counterparties`` and the
    client-cleared flag from ``trades`` onto ``exposures`` and writes a
    new ``risk_weight`` column with the regulatory trade-exposure weight:

        is_qccp=True,  is_client_cleared=False -> 0.02 (Art. 306(1)(a))
        is_qccp=True,  is_client_cleared=True  -> 0.04 (Art. 306(1)(c))
        is_qccp=False                          -> NULL (pass-through to SA)

    The non-QCCP NULL pass-through is intentional: the 20% SA-institution
    weight for CQS-1 is applied by the downstream classifier (P8.30),
    not here. Signalling pass-through via NULL keeps the routing layer
    able to detect which rows have already had a regulatory weight set.

    Load-bearing invariant: ``ead_ccr`` is never mutated by this function.
    EAD is produced upstream by SA-CCR (Art. 274) and must be identical
    across all three CCR-B1 variants (proprietary, client-cleared,
    non-QCCP).

    Args:
        exposures: LazyFrame carrying ``ead_ccr``. Other columns pass
            through unchanged.
        counterparties: LazyFrame carrying the ``is_qccp`` Boolean flag
            (CRR Art. 272 Def (88)).
        trades: LazyFrame carrying the ``is_client_cleared`` Boolean
            flag (CRR Art. 306(1)(c) client-cleared trade relationship).

    Returns:
        LazyFrame with the input ``exposures`` columns plus a new
        ``risk_weight: Float64`` column. ``ead_ccr`` is unchanged.

    References:
        - CRR Art. 306(1)(a), 306(1)(c), 306(4); CRR Art. 107(2)(a).
        - BCBS CRE54.14 (2% proprietary), CRE54.15 (4% client-cleared).
    """
    # Reduce counterparties / trades to the single flag column each carries
    # for the QCCP branching decision. We broadcast via cross-join because
    # the test-level ``exposures`` frame is keyless (a single ``ead_ccr``
    # column) and the fixture is single-row per side; in production code
    # the caller would key the joins on counterparty/trade identifiers.
    cp_flag = counterparties.select(pl.col("is_qccp").fill_null(False).alias("is_qccp"))
    trade_flag = trades.select(
        pl.col("is_client_cleared").fill_null(False).alias("is_client_cleared")
    )

    joined = exposures.join(cp_flag, how="cross").join(trade_flag, how="cross")

    proprietary_rw = _QCCP_PROPRIETARY_RW
    client_cleared_rw = _QCCP_CLIENT_CLEARED_RW

    return joined.with_columns(
        pl.when(pl.col("is_qccp") & pl.col("is_client_cleared"))
        .then(pl.lit(client_cleared_rw))
        .when(pl.col("is_qccp") & ~pl.col("is_client_cleared"))
        .then(pl.lit(proprietary_rw))
        .otherwise(pl.lit(None, dtype=pl.Float64))
        .alias("risk_weight")
    )

CRR Art. 501 — Adjustment of risk-weighted non-defaulted SME exposures

apply_supporting_factors — src/rwa_calc/engine/sa/factors_output.py:64
@cites("CRR Art. 501")
def apply_supporting_factors(
    lf: pl.LazyFrame,
    config: CalculationConfig,
    *,
    errors: list[CalculationError] | None = None,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """Apply SME / infrastructure supporting factors (CRR Art. 501 / 501a).

    Under Basel 3.1 the supporting-factor calculator returns a factor of
    1.0 for every row, preserving RWA unchanged.

    Args:
        lf: SA exposures frame with ``rwa_pre_factor`` computed.
        config: Calculation configuration (selects framework).
        errors: Optional accumulator for data-quality warnings.
    """
    lf = ensure_columns(lf, _SUPPORTING_FACTOR_COLUMNS)
    return SupportingFactorCalculator().apply_factors(lf, config, errors=errors, pack=pack)
calculate_sme_factor — src/rwa_calc/engine/supporting_factors.py:90
    @cites("CRR Art. 501")
    def calculate_sme_factor(
        self,
        total_exposure: Decimal,
        config: CalculationConfig,
        *,
        pack: ResolvedRulepack | None = None,
    ) -> Decimal:
        """
        Calculate SME supporting factor based on total drawn exposure.

        Args:
            total_exposure: Total drawn (on-balance-sheet) amount to the SME
            config: Calculation configuration

        Returns:
            Effective supporting factor (0.7619 to 0.85)
        """
        resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
        if not resolved_pack.feature("supporting_factors"):
            return Decimal("1.0")

        if total_exposure <= 0:
            return Decimal("1.0")

        # FX-derived SME exposure threshold stays config (RegulatoryThresholds → S11c);
        # the factor multipliers are pack-sourced (Decimal, exact).
        threshold_gbp = regulatory_threshold(
            resolved_pack, "sme_exposure_threshold", config.eur_gbp_rate
        )

        sf_values = resolved_pack.formula("supporting_factors_values").params
        factor_tier1 = sf_values["sme_factor_under_threshold"]
        factor_tier2 = sf_values["sme_factor_above_threshold"]

        # Use GBP threshold for GBP currency (default)
        threshold = threshold_gbp

        # Calculate tiered factor
        tier1_amount = min(total_exposure, threshold)
        tier2_amount = max(total_exposure - threshold, Decimal("0"))

        weighted_factor = tier1_amount * factor_tier1 + tier2_amount * factor_tier2

        return weighted_factor / total_exposure
apply_factors — src/rwa_calc/engine/supporting_factors.py:197
    @cites("CRR Art. 501")
    def apply_factors(
        self,
        exposures: pl.LazyFrame,
        config: CalculationConfig,
        *,
        errors: list[CalculationError] | None = None,
        pack: ResolvedRulepack | None = None,
    ) -> pl.LazyFrame:
        """
        Apply supporting factors to exposures LazyFrame.

        The SME supporting factor threshold (EUR 2.5m) is applied to E*,
        which CRR Art. 501 defines as the total drawn amount owed by the SME's
        group of connected clients, excluding claims secured on residential
        property collateral. Aggregation runs on the unified frame **before**
        the pipeline's SA / IRB / slotting branch split via
        ``compute_e_star_group_drawn``; this method reads the pre-computed
        ``e_star_group_drawn`` column when present (production path) and
        falls back to a local windowed sum over ``lending_group_reference``
        (with fallback to ``counterparty_reference``) when the column is
        absent (test harnesses that bypass the pipeline). The residential
        carve-out is applied per row by subtracting
        ``residential_collateral_value`` (capped at drawn) from each row's
        contribution to E*, mirroring the retail-threshold logic in
        ``engine/hierarchy.py`` (Art. 123(c)). BTL rows receive factor=1.0
        via a separate eligibility gate. The resulting blended factor is
        applied to each SME row's full RWA.

        The tier calculation uses drawn_amount + interest ("amount owed"),
        NOT ead_final which includes CCF-adjusted undrawn commitments.

        Expects columns:
        - is_sme: bool
        - is_infrastructure: bool
        - drawn_amount: float (on-balance-sheet drawn amount)
        - interest: float (accrued interest)
        - ead_final: float (fallback if drawn_amount not available)
        - rwa_pre_factor: float (RWA before supporting factor)
        - counterparty_reference: str (optional, for fallback aggregation)
        - lending_group_reference: str (optional, primary aggregation key)
        - residential_collateral_value: float (optional, netted from E* per
          Art. 501 residential carve-out)
        - is_buy_to_let: bool (optional, factor=1.0 eligibility gate)
        - e_star_group_drawn: float (optional, pre-computed unified-frame E*
          from ``compute_e_star_group_drawn`` — when present, the per-branch
          windowed sum is bypassed and this column is used directly so the
          tier threshold honours cross-approach siblings)

        Adds columns:
        - supporting_factor: float
        - rwa_post_factor: float (RWA after supporting factor)
        - supporting_factor_applied: bool
        - total_cp_drawn: float (E* — drawn aggregated across the SME's group of
          connected clients, net of residential collateral per Art. 501)

        Args:
            exposures: Exposures with RWA calculated
            config: Calculation configuration
            errors: Optional error accumulator for data quality warnings

        Returns:
            Exposures with supporting factors applied
        """
        resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
        if not resolved_pack.feature("supporting_factors"):
            # Basel 3.1: No supporting factors
            return exposures.with_columns(
                [
                    pl.lit(1.0).alias("supporting_factor"),
                    pl.col("rwa_pre_factor").alias("rwa_post_factor"),
                    pl.lit(False).alias("supporting_factor_applied"),
                ]
            )

        # FX-derived threshold stays config (RegulatoryThresholds → S11c); the factor
        # multipliers are pack-sourced (float boundary via formula_float_map).
        threshold_gbp = float(
            regulatory_threshold(resolved_pack, "sme_exposure_threshold", config.eur_gbp_rate)
        )
        sf_values = formula_float_map(resolved_pack.formula("supporting_factors_values"))
        factor_tier1 = sf_values["sme_factor_under_threshold"]
        factor_tier2 = sf_values["sme_factor_above_threshold"]
        infra_factor = sf_values["infrastructure_factor"]

        # Check for optional columns (is_sme / is_infrastructure /
        # lending_group_reference are crm_exit contract columns and read
        # directly).
        schema = exposures.collect_schema()
        has_counterparty = "counterparty_reference" in schema.names()
        has_btl = "is_buy_to_let" in schema.names()
        has_defaulted = "is_defaulted" in schema.names()
        has_drawn = "drawn_amount" in schema.names()
        has_res_coll = "residential_collateral_value" in schema.names()

        # Build the drawn (on-balance-sheet) expression for tier calculation.
        # Use drawn_amount + interest when available; fall back to ead_final.
        # fill_nan before clip/sum — a single NaN in the group would otherwise
        # poison the windowed sum and zero out the supporting factor.
        if has_drawn:
            drawn_expr = pl.col("drawn_amount").fill_nan(0.0).fill_null(0.0).clip(
                lower_bound=0.0
            ) + pl.col("interest").fill_nan(0.0).fill_null(0.0)
        else:
            drawn_expr = pl.col("ead_final").fill_nan(0.0).fill_null(0.0)

        # Build SME factor expression with group-of-connected-clients aggregation.
        # CRR Art. 501 defines E* as the total amount owed across the SME's group
        # of connected clients, excluding claims secured on residential property.
        # The unified-frame helper ``compute_e_star_group_drawn`` (called by the
        # pipeline orchestrator before the approach split) populates
        # ``e_star_group_drawn`` across SA / IRB / slotting rows so the tier
        # calculation honours the full cross-approach group. When that column is
        # absent (test harnesses that bypass the pipeline) we fall back to the
        # legacy per-branch windowed sum.
        has_e_star_pre_computed = "e_star_group_drawn" in schema.names()
        if has_e_star_pre_computed:
            # Pre-computed unified-frame E* (CRR Art. 501 cross-approach).
            # Mirror to ``total_cp_drawn`` so downstream consumers and the
            # output schema stay stable.
            exposures = exposures.with_columns(pl.col("e_star_group_drawn").alias("total_cp_drawn"))
            ead_for_tier = pl.col("total_cp_drawn")
        elif has_counterparty:
            group_key_expr = (
                pl.when(pl.col("lending_group_reference").is_not_null())
                .then(pl.col("lending_group_reference"))
                .otherwise(pl.col("counterparty_reference"))
            ).alias("_sme_group_key")

            exposures = exposures.with_columns([group_key_expr])

            # Art. 501 carve-out: "excluding claims or contingent claims
            # secured on residential property collateral". Implemented as
            # per-row netting of residential_collateral_value (capped at
            # drawn so the contribution never goes negative), mirroring
            # the retail-threshold logic in engine/hierarchy.py:2444-2447
            # (Art. 123(c)). Defaulted exposures stay in E* (Art. 501
            # explicitly includes "any exposure in default").
            if has_res_coll:
                res_coll_expr = (
                    pl.col("residential_collateral_value")
                    .fill_nan(0.0)
                    .fill_null(0.0)
                    .clip(lower_bound=0.0)
                )
                drawn_in_e_star = drawn_expr - pl.min_horizontal(res_coll_expr, drawn_expr)
            else:
                drawn_in_e_star = drawn_expr

            total_cp_drawn_expr = (
                pl.when(pl.col("is_sme") & pl.col("_sme_group_key").is_not_null())
                .then(drawn_in_e_star.sum().over("_sme_group_key"))
                .otherwise(drawn_in_e_star)
            )
            exposures = exposures.with_columns([total_cp_drawn_expr.alias("total_cp_drawn")])
            ead_for_tier = pl.col("total_cp_drawn")
        else:
            # counterparty_reference is not present — per-exposure fallback.
            # This can misclassify the tier when multiple exposures to the
            # same group individually fall below the EUR 2.5m threshold but
            # aggregate above it (Art. 501 requires aggregation across the
            # SME's group of connected clients).
            if errors is not None:
                errors.append(
                    CalculationError(
                        code=ERROR_SME_MISSING_COUNTERPARTY_REF,
                        message=(
                            "SME supporting factor: neither counterparty_reference "
                            "nor lending_group_reference is available. Tier threshold "
                            "(EUR 2.5m) evaluated per-exposure instead of across the "
                            "SME's group of connected clients as required by CRR "
                            "Art. 501. This may produce an incorrectly low supporting "
                            "factor when multiple exposures to the same group "
                            "individually fall below the threshold but aggregate above it."
                        ),
                        severity=ErrorSeverity.WARNING,
                        category=ErrorCategory.DATA_QUALITY,
                        regulatory_reference="CRR Art. 501",
                        field_name="counterparty_reference",
                    )
                )
            ead_for_tier = drawn_expr

        # Calculate tiered factor based on aggregated drawn exposure
        tier1_expr = (
            pl.when(ead_for_tier <= threshold_gbp)
            .then(ead_for_tier)
            .otherwise(pl.lit(threshold_gbp))
        )

        tier2_expr = (
            pl.when(ead_for_tier > threshold_gbp)
            .then(ead_for_tier - threshold_gbp)
            .otherwise(pl.lit(0.0))
        )

        # BTL exposures are excluded from the SME factor itself (the
        # eligibility gate is separate from the E* netting). For E* the
        # residential carve-out is applied via residential_collateral_value
        # netting on drawn_in_e_star above; a typical BTL row's RRE
        # collateral covers its drawn balance so its E* contribution is 0.
        is_btl = pl.col("is_buy_to_let") if has_btl else pl.lit(False)
        # Defaulted exposures are excluded from SME factor (CRR Art. 501)
        is_defaulted = pl.col("is_defaulted") if has_defaulted else pl.lit(False)
        # Art. 501(2)(c): the SME supporting factor is keyed on annual
        # turnover only — the Commission Rec 2003/361/EC total-assets
        # fallback (used by other SME-classification gates and by the
        # IRB Art. 153(4) correlation adjustment) does NOT apply here.
        # Counterparties identified as SME via assets receive the
        # CORPORATE_SME class and IRB correlation benefit but
        # supporting_factor=1.0. The check is conditional on the column
        # being present so test harnesses that build minimal LazyFrames
        # without cp_annual_revenue still hit the legacy is_sme-only
        # predicate; production pipelines always project this column via
        # the classifier so the gate fires there.
        has_revenue = "cp_annual_revenue" in schema.names()
        turnover_eligible = (
            (pl.col("cp_annual_revenue").is_not_null() & (pl.col("cp_annual_revenue") > 0))
            if has_revenue
            else pl.lit(True)
        )

        sme_eligible = pl.col("is_sme") & turnover_eligible & ~is_btl & ~is_defaulted

        sme_factor_expr = (
            pl.when(sme_eligible & (ead_for_tier > 0))
            .then((tier1_expr * factor_tier1 + tier2_expr * factor_tier2) / ead_for_tier)
            .when(sme_eligible & (ead_for_tier <= 0))
            .then(
                # Zero drawn = all within tier 1 → pure 0.7619
                pl.lit(factor_tier1)
            )
            .otherwise(pl.lit(1.0))
        )

        # Build infrastructure factor expression inline
        infra_factor_expr = (
            pl.when(pl.col("is_infrastructure")).then(pl.lit(infra_factor)).otherwise(pl.lit(1.0))
        )

        # Compute minimum (most beneficial) factor
        min_factor_expr = pl.min_horizontal(sme_factor_expr, infra_factor_expr)

        # Single with_columns call for maximum performance
        return exposures.with_columns(
            [
                min_factor_expr.alias("supporting_factor"),
                (pl.col("rwa_pre_factor") * min_factor_expr).alias("rwa_post_factor"),
                (min_factor_expr < 1.0).alias("supporting_factor_applied"),
            ]
        )
compute_e_star_group_drawn — src/rwa_calc/engine/supporting_factors.py:455
@cites("CRR Art. 501")
def compute_e_star_group_drawn(
    exposures: pl.LazyFrame,
    config: CalculationConfig,
    *,
    errors: list[CalculationError] | None = None,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """
    Compute Art. 501 E* across the unified frame before the approach split.

    The SME supporting factor's EUR 2.5m / GBP 2.2m tier threshold is defined
    by CRR Art. 501 against the total amount owed by the SME's *group of
    connected clients*, regardless of which regulatory approach (SA, IRB,
    slotting) each member is treated under. Running the windowed sum inside
    each branch after the pipeline splits by approach (the historical
    behaviour) under-counts E* whenever a lending group spans multiple
    approaches.

    This helper runs once on the unified frame, before the split in
    ``engine/pipeline.py``, so SA / IRB / slotting siblings all contribute.
    The resulting ``e_star_group_drawn`` column is then read by
    ``apply_factors`` in each branch.

    Population rules (mirroring the existing ``apply_factors`` logic):
    - per-row contribution = ``drawn_amount + interest`` (clipped at zero),
      minus ``min(residential_collateral_value, contribution)``
      (Art. 501 residential carve-out)
    - aggregation key = ``lending_group_reference`` if not null, else
      ``counterparty_reference`` (mirrors the connected-clients pattern)
    - written to every row (SME and non-SME) in a partition so all three
      branch calculators can read it

    No-ops:
    - if supporting factors are disabled (Basel 3.1), returns the frame
      unchanged — column is not added
    - if ``counterparty_reference`` is not present (missing group key),
      emits the existing ``SF001`` warning and returns unchanged

    Args:
        exposures: Unified-frame LazyFrame post-CRM, pre-branch-split
        config: Calculation configuration
        errors: Optional error accumulator for data-quality warnings

    Returns:
        LazyFrame with ``e_star_group_drawn`` column added
    """
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
    if not resolved_pack.feature("supporting_factors"):
        return exposures

    schema = exposures.collect_schema()
    names = schema.names()

    if "counterparty_reference" not in names:
        if errors is not None:
            errors.append(
                CalculationError(
                    code=ERROR_SME_MISSING_COUNTERPARTY_REF,
                    message=(
                        "SME supporting factor: neither counterparty_reference "
                        "nor lending_group_reference is available on the unified "
                        "frame. Cross-approach E* (CRR Art. 501) cannot be "
                        "computed; the tier threshold will fall back to per-branch "
                        "aggregation and may under-count exposures."
                    ),
                    severity=ErrorSeverity.WARNING,
                    category=ErrorCategory.DATA_QUALITY,
                    regulatory_reference="CRR Art. 501",
                    field_name="counterparty_reference",
                )
            )
        return exposures

    has_drawn = "drawn_amount" in names
    has_interest = "interest" in names
    has_res_coll = "residential_collateral_value" in names

    drawn_principal = (
        pl.col("drawn_amount").fill_nan(0.0).fill_null(0.0).clip(lower_bound=0.0)
        if has_drawn
        else pl.lit(0.0)
    )
    interest_expr = pl.col("interest").fill_nan(0.0).fill_null(0.0) if has_interest else pl.lit(0.0)
    drawn_expr = drawn_principal + interest_expr

    if has_res_coll:
        res_coll_expr = (
            pl.col("residential_collateral_value")
            .fill_nan(0.0)
            .fill_null(0.0)
            .clip(lower_bound=0.0)
        )
        drawn_in_e_star = drawn_expr - pl.min_horizontal(res_coll_expr, drawn_expr)
    else:
        drawn_in_e_star = drawn_expr

    group_key_expr = (
        pl.when(pl.col("lending_group_reference").is_not_null())
        .then(pl.col("lending_group_reference"))
        .otherwise(pl.col("counterparty_reference"))
    )

    exposures = exposures.with_columns(group_key_expr.alias("_sme_group_key"))
    exposures = exposures.with_columns(
        drawn_in_e_star.sum().over("_sme_group_key").alias("e_star_group_drawn")
    )
    return exposures.drop("_sme_group_key")

CRR Art. 501a — Adjustment to own funds requirements for credit risk for exposures to entities that operate or finance physical structures or facilities, systems and networks that provide or support essential public services

calculate_infrastructure_factor — src/rwa_calc/engine/supporting_factors.py:136
    @cites("CRR Art. 501a")
    def calculate_infrastructure_factor(
        self,
        config: CalculationConfig,
        *,
        pack: ResolvedRulepack | None = None,
    ) -> Decimal:
        """
        Get infrastructure supporting factor.

        Args:
            config: Calculation configuration

        Returns:
            Infrastructure factor (0.75 for CRR, 1.0 for Basel 3.1)
        """
        resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
        if not resolved_pack.feature("supporting_factors"):
            return Decimal("1.0")

        return resolved_pack.formula("supporting_factors_values").params["infrastructure_factor"]

PS1/26 (PRA Policy Statement)

PS1/26, paragraph 4.8 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

_equity_holding_higher_of_rw — src/rwa_calc/engine/equity/calculator.py:496
    @cites("CRR Art. 155(2)")
    @cites("PS1/26, paragraph 4.8")
    @cites("PS1/26, paragraph 4.9")
    def _equity_holding_higher_of_rw(
        self, config: CalculationConfig, *, pack: ResolvedRulepack | None = None
    ) -> float | None:
        """Rules 4.7-4.8 higher-of RW for EQUITY-class CIU look-through holdings.

        Returns ``max(legacy Art. 155(2) "other equity" simple RW, Rule 4.2/4.3
        transitional SA RW)`` when the Basel 3.1 equity transitional regime is
        active for the reporting date, else ``None`` (no override — holdings keep
        the _DEFAULT_HOLDING_RW fallback).

        The transitional regime only applies to firms that held IRB equity
        permission, so ``equity_transitional.enabled`` (plus a transitional RW
        existing for the reporting date) is the gate.

        Per Rule 4.9-4.10, a firm that has irrevocably opted out of the
        transitional regime (``equity_transitional.opt_out``) suppresses the
        higher-of: ``None`` is returned so the holding falls back to the
        ``_DEFAULT_HOLDING_RW`` standard treatment. The opt-out applies jointly
        with the direct-equity transitional floor (Rule 4.9).

        References:
        - CRR Art. 155(2): IRB simple method equity RW ("other" = 370%).
        - PRA PS1/26 Rule 4.8: higher-of(Art. 155(2) simple, Rule 4.2/4.3 band).
        - PRA PS1/26 Rule 4.9-4.10: irrevocable joint opt-out suppresses higher-of.
        """
        if config.equity_transitional.opt_out:
            return None

        resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
        transitional_rw = _equity_transitional_rw(
            resolved_pack, config.reporting_date, is_higher_risk=False
        )
        if transitional_rw is None:
            return None

        legacy_simple_rw = _IRB_RW[EquityType.OTHER]
        return max(legacy_simple_rw, float(transitional_rw))

PS1/26, paragraph 4.9 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

_equity_holding_higher_of_rw — src/rwa_calc/engine/equity/calculator.py:497
    @cites("CRR Art. 155(2)")
    @cites("PS1/26, paragraph 4.8")
    @cites("PS1/26, paragraph 4.9")
    def _equity_holding_higher_of_rw(
        self, config: CalculationConfig, *, pack: ResolvedRulepack | None = None
    ) -> float | None:
        """Rules 4.7-4.8 higher-of RW for EQUITY-class CIU look-through holdings.

        Returns ``max(legacy Art. 155(2) "other equity" simple RW, Rule 4.2/4.3
        transitional SA RW)`` when the Basel 3.1 equity transitional regime is
        active for the reporting date, else ``None`` (no override — holdings keep
        the _DEFAULT_HOLDING_RW fallback).

        The transitional regime only applies to firms that held IRB equity
        permission, so ``equity_transitional.enabled`` (plus a transitional RW
        existing for the reporting date) is the gate.

        Per Rule 4.9-4.10, a firm that has irrevocably opted out of the
        transitional regime (``equity_transitional.opt_out``) suppresses the
        higher-of: ``None`` is returned so the holding falls back to the
        ``_DEFAULT_HOLDING_RW`` standard treatment. The opt-out applies jointly
        with the direct-equity transitional floor (Rule 4.9).

        References:
        - CRR Art. 155(2): IRB simple method equity RW ("other" = 370%).
        - PRA PS1/26 Rule 4.8: higher-of(Art. 155(2) simple, Rule 4.2/4.3 band).
        - PRA PS1/26 Rule 4.9-4.10: irrevocable joint opt-out suppresses higher-of.
        """
        if config.equity_transitional.opt_out:
            return None

        resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
        transitional_rw = _equity_transitional_rw(
            resolved_pack, config.reporting_date, is_higher_risk=False
        )
        if transitional_rw is None:
            return None

        legacy_simple_rw = _IRB_RW[EquityType.OTHER]
        return max(legacy_simple_rw, float(transitional_rw))

PS1/26, paragraph 92 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

apply_floor_with_impact — src/rwa_calc/engine/aggregator/_floor.py:108
@cites("PS1/26, paragraph 92")
def apply_floor_with_impact(
    combined: pl.LazyFrame,
    sa_results: pl.LazyFrame,
    floor_pct: float,
    of_adj: float = 0.0,
    irb_t2_credit: float = 0.0,
    irb_cet1_deduction: float = 0.0,
    gcra_amount: float = 0.0,
    sa_t2_credit: float = 0.0,
) -> tuple[pl.LazyFrame, pl.LazyFrame, OutputFloorSummary]:
    """
    Apply portfolio-level output floor and generate impact analysis.

    The floor is applied at portfolio level per PRA PS1/26 Art. 92 para 2A:
    ``TREA = max(U-TREA, x * S-TREA + OF-ADJ)``. When the floor binds, the
    shortfall (``x * S-TREA + OF-ADJ - U-TREA``) is distributed pro-rata
    across floor-eligible exposures (IRB + slotting) proportional to each
    exposure's ``sa_rwa``.

    Args:
        combined: Combined results with ``rwa_final`` column.
        sa_results: SA results to derive floor RWA from.
        floor_pct: Floor percentage (e.g. 0.725 for 72.5%).
        of_adj: Pre-computed OF-ADJ amount (default 0.0 for backward compat).
        irb_t2_credit: Art. 62(d) IRB T2 credit (for summary reporting).
        irb_cet1_deduction: Art. 36(1)(d) + Art. 40 CET1 deductions (for summary).
        gcra_amount: GCRA after cap (for summary reporting).
        sa_t2_credit: Art. 62(c) SA T2 credit (for summary reporting).

    Returns:
        Tuple of (floored results, floor impact analysis, portfolio summary).
    """
    # Ensure combined has rwa_final column
    combined_cols = set(combined.collect_schema().names())
    if "rwa_final" not in combined_cols:
        rwa_col = resolve_rwa_col(combined_cols)
        if rwa_col:
            combined = combined.with_columns(pl.col(rwa_col).alias("rwa_final"))
        else:
            combined = combined.with_columns(pl.lit(0.0).alias("rwa_final"))

    # Store pre-floor RWA for impact calculation
    combined = combined.with_columns(pl.col("rwa_final").alias("rwa_pre_floor"))

    # Get SA RWA for each exposure. If calculate_unified already stored
    # sa_rwa inline (single-pass path), use it directly. Otherwise join
    # from the separate SA results frame (aggregate_with_audit path).
    combined_cols = set(combined.collect_schema().names())
    if "sa_rwa" in combined_cols:
        result = combined
    else:
        sa_cols = set(sa_results.collect_schema().names())
        sa_rwa_col = resolve_rwa_col(sa_cols)
        if not sa_rwa_col:
            sa_rwa_total, equity_rwa_total = _portfolio_sa_equity_totals(combined)
            summary = OutputFloorSummary(
                u_trea=0.0,
                s_trea=0.0,
                floor_pct=floor_pct,
                floor_threshold=0.0,
                shortfall=0.0,
                portfolio_floor_binding=False,
                floored_modelled_rwa=0.0,
                of_adj=of_adj,
                irb_t2_credit=irb_t2_credit,
                irb_cet1_deduction=irb_cet1_deduction,
                gcra_amount=gcra_amount,
                sa_t2_credit=sa_t2_credit,
                sa_rwa_total=sa_rwa_total,
                equity_rwa_total=equity_rwa_total,
                total_rwa_post_floor=sa_rwa_total + equity_rwa_total,
            )
            return combined, empty_frame(FLOOR_IMPACT_SCHEMA), summary

        sa_rwa = sa_results.select(
            pl.col("exposure_reference"),
            pl.col(sa_rwa_col).alias("sa_rwa"),
        )
        result = combined.join(sa_rwa, on="exposure_reference", how="left", suffix="_sa")

    # --- Portfolio-level output floor (Art. 92 para 2A) ---
    #
    # 1. Compute portfolio totals: U-TREA and S-TREA for floor-eligible
    #    exposures (IRB + slotting). SA exposures cancel out (same RWA
    #    in both U-TREA and S-TREA) so we only need the modelled subset.
    #
    # 2. Floor threshold = x * S-TREA + OF-ADJ.  OF-ADJ reconciles the
    #    different provision treatments (IRB EL vs SA general CRA).
    #
    # 3. If floor binds (threshold > U-TREA), distribute the shortfall
    #    pro-rata by each exposure's sa_rwa share.
    #
    # 4. Per-exposure columns: floor_rwa, floor_impact_rwa, is_floor_binding,
    #    rwa_final (post-floor), output_floor_pct for COREP reporting.
    floor_eligible_approaches = list(FLOOR_ELIGIBLE_APPROACHES)
    is_eligible = pl.col("approach_applied").is_in(floor_eligible_approaches)
    sa_rwa_filled = pl.col("sa_rwa").fill_null(0.0)

    result = (
        result
        # Step 1: Portfolio-level totals (broadcast as scalar to every row)
        .with_columns(
            pl.when(is_eligible)
            .then(pl.col("rwa_pre_floor"))
            .otherwise(0.0)
            .sum()
            .alias("_u_trea"),
            pl.when(is_eligible).then(sa_rwa_filled).otherwise(0.0).sum().alias("_s_trea"),
        )
        # Step 2: Floor threshold = x * S-TREA + OF-ADJ
        .with_columns(
            (pl.col("_s_trea") * floor_pct + pl.lit(of_adj)).alias("_floor_threshold"),
            pl.max_horizontal(
                pl.col("_s_trea") * floor_pct + pl.lit(of_adj) - pl.col("_u_trea"),
                pl.lit(0.0),
            ).alias("_shortfall"),
            (pl.col("_s_trea") * floor_pct + pl.lit(of_adj) > pl.col("_u_trea")).alias(
                "_portfolio_floor_binds"
            ),
        )
        # Step 3: Each eligible exposure's share of total S-TREA
        .with_columns(
            pl.when(is_eligible & (pl.col("_s_trea") > 0))
            .then(sa_rwa_filled / pl.col("_s_trea"))
            .otherwise(0.0)
            .alias("_sa_share"),
        )
        # Step 4: Per-exposure floor columns
        .with_columns(
            (sa_rwa_filled * floor_pct).alias("floor_rwa"),
            pl.lit(floor_pct).alias("output_floor_pct"),
            # Pro-rata add-on: shortfall × this exposure's S-TREA share
            pl.when(is_eligible)
            .then(pl.col("_shortfall") * pl.col("_sa_share"))
            .otherwise(0.0)
            .alias("floor_impact_rwa"),
            # Portfolio-level binding flag (same for all eligible rows)
            pl.when(is_eligible)
            .then(pl.col("_portfolio_floor_binds"))
            .otherwise(pl.lit(False))
            .alias("is_floor_binding"),
        )
        # Step 5: Final RWA = pre-floor + pro-rata add-on
        .with_columns(
            pl.when(is_eligible)
            .then(pl.col("rwa_pre_floor") + pl.col("floor_impact_rwa"))
            .otherwise(pl.col("rwa_pre_floor"))
            .alias("rwa_final"),
        )
    )

    # Extract portfolio-level summary (requires one collect — acceptable at
    # the aggregator boundary per project convention).  fill_null handles
    # the edge case of zero-row input (all sums are null → 0.0).
    #
    # SA and equity row totals are computed from the same frame so that the
    # genuine portfolio total (total_rwa_post_floor) reflects every approach,
    # not just the floor-eligible (modelled) subset.  See P2.20.
    sa_approaches = list(SA_APPROACHES)
    equity_approaches = list(EQUITY_APPROACHES)
    is_sa = pl.col("approach_applied").is_in(sa_approaches)
    is_equity = pl.col("approach_applied").is_in(equity_approaches)

    summary_row = result.select(
        pl.col("_u_trea").first().fill_null(0.0),
        pl.col("_s_trea").first().fill_null(0.0),
        pl.col("_floor_threshold").first().fill_null(0.0),
        pl.col("_shortfall").first().fill_null(0.0),
        pl.col("_portfolio_floor_binds").first().fill_null(False),
        pl.when(is_sa)
        .then(pl.col("rwa_pre_floor"))
        .otherwise(0.0)
        .sum()
        .fill_null(0.0)
        .alias("_sa_rwa_total"),
        pl.when(is_equity)
        .then(pl.col("rwa_pre_floor"))
        .otherwise(0.0)
        .sum()
        .fill_null(0.0)
        .alias("_equity_rwa_total"),
    ).collect()

    u_trea = float(summary_row["_u_trea"][0])
    s_trea = float(summary_row["_s_trea"][0])
    floor_threshold = float(summary_row["_floor_threshold"][0])
    shortfall = float(summary_row["_shortfall"][0])
    binding = bool(summary_row["_portfolio_floor_binds"][0])
    sa_rwa_total = float(summary_row["_sa_rwa_total"][0])
    equity_rwa_total = float(summary_row["_equity_rwa_total"][0])
    floored_modelled_rwa = u_trea + shortfall

    summary = OutputFloorSummary(
        u_trea=u_trea,
        s_trea=s_trea,
        floor_pct=floor_pct,
        floor_threshold=floor_threshold,
        shortfall=shortfall,
        portfolio_floor_binding=binding,
        floored_modelled_rwa=floored_modelled_rwa,
        of_adj=of_adj,
        irb_t2_credit=irb_t2_credit,
        irb_cet1_deduction=irb_cet1_deduction,
        gcra_amount=gcra_amount,
        sa_t2_credit=sa_t2_credit,
        sa_rwa_total=sa_rwa_total,
        equity_rwa_total=equity_rwa_total,
        total_rwa_post_floor=floored_modelled_rwa + sa_rwa_total + equity_rwa_total,
    )

    # Drop internal columns
    result = result.drop(
        [
            "_u_trea",
            "_s_trea",
            "_floor_threshold",
            "_shortfall",
            "_portfolio_floor_binds",
            "_sa_share",
        ],
        strict=False,
    )

    # Generate floor impact analysis (floor-eligible rows only)
    result_cols = set(result.collect_schema().names())
    floor_impact = result.select(
        pl.col("exposure_reference"),
        pl.col("approach_applied"),
        col_or_default("exposure_class", result_cols),
        pl.col("rwa_pre_floor"),
        pl.col("floor_rwa"),
        pl.col("is_floor_binding"),
        pl.col("floor_impact_rwa"),
        pl.col("rwa_final").alias("rwa_post_floor"),
        pl.col("output_floor_pct"),
    ).filter(pl.col("approach_applied").is_in(floor_eligible_approaches))

    return result, floor_impact, summary
aggregate — src/rwa_calc/engine/aggregator/aggregator.py:75
    @cites("PS1/26, paragraph 92")
    def aggregate(
        self,
        sa_results: pl.LazyFrame,
        irb_results: pl.LazyFrame,
        slotting_results: pl.LazyFrame,
        equity_bundle: EquityResultBundle | None,
        config: CalculationConfig,
        securitisation_audit: pl.LazyFrame | None = None,
        *,
        pack: ResolvedRulepack | None = None,
    ) -> AggregatedResultBundle:
        """
        Aggregate calculator outputs into final result bundle.

        Args:
            sa_results: SA branch results (already collected and re-lazied).
            irb_results: IRB branch results.
            slotting_results: Slotting branch results.
            equity_bundle: Equity result bundle (optional, separate path).
            config: Calculation configuration.
            securitisation_audit: Resolved securitisation lookup from the
                allocator stage (one row per securitised exposure carrying
                residual_pct + pool_allocations + audit_status). None when
                no allocations were supplied.
            pack: Resolved rulepack for the run's regime/date (Phase 5 — sources
                the ``output_floor`` / ``supporting_factors`` regime gates).
                Production threads the orchestrator's pack; direct callers may
                omit it, in which case one is resolved from ``config``.

        Returns:
            AggregatedResultBundle with all summaries and adjustments. Every
            frame field is eager-backed: the summary views are collected once
            here (in two ``_collect_views`` batches, pre- and post-floor) and
            wrapped back with ``.lazy()``, so a downstream collect call is a
            near-free shallow collect rather than a plan re-execution.
        """
        resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack

        # Combine for summaries (data already materialised — cheap concat).
        # ``combined_unmultiplied`` retains the full ead_final / rwa_final
        # values so the per-pool securitisation summary can multiply by each
        # pool's allocation_pct against the un-multiplied parent total. The
        # main ``combined`` then gets the residual multiplier applied so the
        # existing summaries (by class, by approach, floor, EL, supporting
        # factors) naturally reflect the on-balance-sheet residual only --
        # ``ead_final × (1 - securitisation_pct)`` in the user's words.
        combined_unmultiplied = pl.concat(
            [sa_results, irb_results, slotting_results], how="diagonal_relaxed"
        )

        # Concat equity if present
        equity_results = None
        if equity_bundle and equity_bundle.results is not None:
            equity_prepared = prepare_equity_results(equity_bundle.results)
            combined_unmultiplied = pl.concat(
                [combined_unmultiplied, equity_prepared], how="diagonal_relaxed"
            )
            equity_results = equity_bundle.results

        # Build the per-pool summary and the per-exposure reconciliation
        # BEFORE applying the residual multiplier -- the pool slice needs
        # the un-multiplied parent EAD.
        securitisation_summary = generate_securitisation_summary(combined_unmultiplied)
        sec_audit_view = generate_securitisation_audit(combined_unmultiplied, securitisation_audit)

        # Apply the residual multiplier in-place so every downstream
        # summary, floor calc, and EL roll-up reflects only the on-balance-
        # sheet portion. When no allocations are present, the multiplier
        # column is a uniform 1.0 and this is a no-op.
        combined = apply_residual_multiplier(combined_unmultiplied)

        # Pre-CRM summary uses the original (pre-substitution) class and is
        # unaffected by the output floor, so it is built from the current
        # ``combined``.  The post-CRM reporting views and the by-class /
        # by-approach summaries are deferred until AFTER the output floor is
        # applied below, so they reflect the floored per-row RWA (P1.130).
        pre_crm_summary = generate_pre_crm_summary(combined)

        # Materialise the pre-floor views ONCE.  The calculator branches are
        # already eager (collected by materialise_branches at the calculator
        # edge), so these are plans over in-memory data; one pl.collect_all
        # shares the common subplan (concat + residual multiplier) across the
        # views.  Each frame is wrapped back with ``.lazy()`` so the bundle
        # fields stay LazyFrame-typed (migration Phase 1 — no bundle type
        # changes until the Phase 3 producer seal).
        pre_floor_views: dict[str, pl.LazyFrame] = {
            "combined": combined,
            "pre_crm_summary": pre_crm_summary,
        }
        if securitisation_summary is not None:
            pre_floor_views["securitisation_summary"] = securitisation_summary
        if sec_audit_view is not None:
            pre_floor_views["securitisation_audit"] = sec_audit_view
        pre_floor_dfs = _collect_views(pre_floor_views)

        combined_df = pre_floor_dfs["combined"]
        combined = combined_df.lazy()

        # CCR Art. 308/309 default-fund-contribution roll-up: sum rwa_final over
        # the synthetic ``CCR_DEFAULT_FUND`` rows. Guarded for the column's
        # absence on CCR-free portfolios (risk_type is null/absent there).
        rwa_ccr_default_fund: float | None = None
        if {"risk_type", "rwa_final"} <= set(combined_df.columns):
            dfc_total = float(
                combined_df.filter(pl.col("risk_type") == "CCR_DEFAULT_FUND")
                .select(pl.col("rwa_final").fill_null(0.0).sum())
                .item()
            )
            if dfc_total > 0.0:
                rwa_ccr_default_fund = dfc_total

        # P8.52 CCR reporting roll-ups (COREP / Pillar-III scalars). Each is a
        # filtered sum over the already-materialised ``combined_df``; column-
        # presence-guarded so a CCR-free portfolio yields ``None`` not a raise.
        #
        # ead_ccr_total — CRR Art. 274(2): sum of ead_final over the synthetic
        # ``ccr__``-prefixed CCR derivative / SFT rows.
        ead_ccr_total: float | None = None
        if {"exposure_reference", "ead_final"} <= set(combined_df.columns):
            ead_total = float(
                combined_df.filter(pl.col("exposure_reference").str.starts_with("ccr__"))
                .select(pl.col("ead_final").sum())
                .item()
            )
            if ead_total > 0.0:
                ead_ccr_total = ead_total

        # rwa_ccr_default / rwa_ccr_qccp_trade — partition of the ``ccr__`` row
        # set by the QCCP trade-leg discriminator (cp_entity_type == "ccp" AND
        # cp_is_qccp.fill_null(True), mirroring the SA QCCP override). Default
        # is the non-QCCP complement (CRR Art. 107(2)(a)); qccp_trade is the
        # QCCP partition (CRR Art. 306(1)/(4)).
        rwa_ccr_default: float | None = None
        rwa_ccr_qccp_trade: float | None = None
        if {
            "exposure_reference",
            "rwa_final",
            "cp_entity_type",
            "cp_is_qccp",
        } <= set(combined_df.columns):
            ccr_rows = combined_df.filter(pl.col("exposure_reference").str.starts_with("ccr__"))
            is_qccp_trade = (pl.col("cp_entity_type") == "ccp") & pl.col("cp_is_qccp").fill_null(
                True
            )
            default_total = float(
                ccr_rows.filter(~is_qccp_trade).select(pl.col("rwa_final").sum()).item()
            )
            if default_total > 0.0:
                rwa_ccr_default = default_total
            qccp_total = float(
                ccr_rows.filter(is_qccp_trade).select(pl.col("rwa_final").sum()).item()
            )
            if qccp_total > 0.0:
                rwa_ccr_qccp_trade = qccp_total

        # failed_trades_rwa — CRR Art. 378-380 / Art. 92(3)(ca): sum of
        # rwa_final over the synthetic ``SETTLEMENT_FAILED_TRADE`` rows.
        failed_trades_rwa: float | None = None
        if {"risk_type", "rwa_final"} <= set(combined_df.columns):
            ft_total = float(
                combined_df.filter(pl.col("risk_type") == "SETTLEMENT_FAILED_TRADE")
                .select(pl.col("rwa_final").sum())
                .item()
            )
            if ft_total > 0.0:
                failed_trades_rwa = ft_total

        pre_crm_summary = pre_floor_dfs["pre_crm_summary"].lazy()
        if securitisation_summary is not None:
            securitisation_summary = pre_floor_dfs["securitisation_summary"].lazy()
        if sec_audit_view is not None:
            sec_audit_view = pre_floor_dfs["securitisation_audit"].lazy()

        # EL portfolio summary (T2 credit cap, CET1/T2 deductions)
        # Computed BEFORE the output floor because OF-ADJ depends on EL summary
        # results (IRB T2 credit and IRB CET1 deduction).
        #
        # IMPORTANT: The T2 credit cap (Art. 62(d)) uses un-floored IRB RWA,
        # not post-floor TREA.  Art. 62(d) references "risk-weighted exposure
        # amounts calculated under Chapter 3 of Title II of Part Three" — the
        # IRB chapter — not the portfolio-level floor from Art. 92(2A).
        # We intentionally pass the original irb_results / slotting_results
        # (which are unaffected by the floor applied to `combined` above),
        # NOT the floored `combined` LazyFrame.  Using post-floor TREA would
        # also create a circular dependency with the OF-ADJ formula.
        #
        # Securitisation: feed the residual-multiplied views so EL / PoolB /
        # T2 cap arithmetic reflects only the on-balance-sheet portion. The
        # IRB EL formula scales linearly with EAD, so this is equivalent to
        # multiplying the final EL summary by the residual fraction.
        el_summary = compute_el_portfolio_summary(
            apply_residual_multiplier(irb_results),
            apply_residual_multiplier(slotting_results),
        )

        # Apply portfolio-level output floor if applicable (Art. 92 para 2A)
        # Floor only applies to specific (institution_type, reporting_basis)
        # combinations — exempt entities use U-TREA with no floor add-on.
        floor_impact = None
        output_floor_summary = None
        if resolved_pack.feature("output_floor") and config.output_floor.is_entity_in_scope():
            floor_pct = float(
                _output_floor_pct(resolved_pack, config.output_floor, config.reporting_date)
            )

            # Compute OF-ADJ from EL summary + capital-tier config inputs
            # OF-ADJ = 12.5 * (IRB_T2 - IRB_CET1 - GCRA + SA_T2)
            # ELPortfolioSummary stores Decimal; convert to float for floor arithmetic.
            irb_t2 = float(el_summary.t2_credit) if el_summary else 0.0
            irb_cet1 = (
                float(el_summary.cet1_deduction) if el_summary else 0.0
            ) + config.output_floor.art_40_deductions
            gcra = config.output_floor.gcra_amount
            sa_t2 = config.output_floor.sa_t2_credit

            # S-TREA is needed for GCRA cap — pre-compute it here.
            # We need a quick aggregate of SA-equivalent RWA for floor-eligible
            # exposures.  This duplicates some work in apply_floor_with_impact
            # but avoids restructuring the floor module's internal flow.
            # Computed eagerly from ``combined_df`` (materialised above) so no
            # extra plan execution is needed.
            from rwa_calc.engine.aggregator._schemas import FLOOR_ELIGIBLE_APPROACHES

            if "approach_applied" in combined_df.columns:
                sa_rwa_col = "sa_rwa" if "sa_rwa" in combined_df.columns else "rwa_final"
                s_trea_pre = float(
                    combined_df.filter(
                        pl.col("approach_applied").is_in(list(FLOOR_ELIGIBLE_APPROACHES))
                    )
                    .select(pl.col(sa_rwa_col).fill_null(0.0).sum())
                    .item()
                )
            else:
                s_trea_pre = 0.0

            of_adj_val, gcra_capped = compute_of_adj(
                irb_t2, irb_cet1, gcra, sa_t2, s_trea_pre, pack=resolved_pack
            )

            combined, floor_impact, output_floor_summary = apply_floor_with_impact(
                combined,
                combined,  # SA-equivalent RW already joined by SA calculator
                floor_pct,
                of_adj=of_adj_val,
                irb_t2_credit=irb_t2,
                irb_cet1_deduction=irb_cet1,
                gcra_amount=gcra_capped,
                sa_t2_credit=sa_t2,
            )

        # Generate post-CRM reporting views from the (possibly floored)
        # ``combined`` frame.  When the floor binds, ``combined`` now carries
        # the per-row ``floor_impact_rwa`` add-on, which the by-class /
        # by-approach summaries fold into ``total_rwa`` so the reported totals
        # reconcile with ``output_floor_summary.total_rwa_post_floor`` (P1.130).
        # When the floor does not run (or does not bind), ``combined`` is the
        # pre-floor frame and these views are identical to the pre-fix output.
        post_crm_detailed = generate_post_crm_detailed(combined)
        post_crm_summary = generate_post_crm_summary(post_crm_detailed)
        summary_by_class = generate_summary_by_class(post_crm_detailed)
        summary_by_approach = generate_summary_by_approach(post_crm_detailed)

        # Supporting factor impact. The regime gate is pack Feature-sourced; the
        # pack is threaded into aggregate() (S11d), so this reads the run's
        # resolved pack directly rather than re-deriving one from config.
        supporting_factor_impact = None
        if resolved_pack.feature("supporting_factors"):
            supporting_factor_impact = generate_supporting_factor_impact(combined)

        # Materialise the post-floor views ONCE (same single-collect pattern
        # as the pre-floor batch).  ``None`` fields stay None — only frames
        # that were actually built are collected.
        post_floor_views: dict[str, pl.LazyFrame] = {
            "results": combined,
            "post_crm_detailed": post_crm_detailed,
            "post_crm_summary": post_crm_summary,
            "summary_by_class": summary_by_class,
            "summary_by_approach": summary_by_approach,
        }
        if floor_impact is not None:
            post_floor_views["floor_impact"] = floor_impact
        if supporting_factor_impact is not None:
            post_floor_views["supporting_factor_impact"] = supporting_factor_impact
        post_floor_dfs = _collect_views(post_floor_views)

        return AggregatedResultBundle(
            # Producer seal (Phase 3): the aggregator's combined results
            # frame is the reporting input contract — pure plan ops over
            # the eager-backed wrap.
            results=seal(post_floor_dfs["results"].lazy(), AGGREGATOR_EXIT_EDGE),
            sa_results=sa_results,
            irb_results=irb_results,
            slotting_results=slotting_results,
            equity_results=equity_results,
            floor_impact=(
                post_floor_dfs["floor_impact"].lazy() if floor_impact is not None else None
            ),
            output_floor_summary=output_floor_summary,
            supporting_factor_impact=(
                post_floor_dfs["supporting_factor_impact"].lazy()
                if supporting_factor_impact is not None
                else None
            ),
            summary_by_class=post_floor_dfs["summary_by_class"].lazy(),
            summary_by_approach=post_floor_dfs["summary_by_approach"].lazy(),
            pre_crm_summary=pre_crm_summary,
            post_crm_detailed=post_floor_dfs["post_crm_detailed"].lazy(),
            post_crm_summary=post_floor_dfs["post_crm_summary"].lazy(),
            el_summary=el_summary,
            securitisation_summary=securitisation_summary,
            securitisation_audit=sec_audit_view,
            rwa_ccr_default_fund=rwa_ccr_default_fund,
            ead_ccr_total=ead_ccr_total,
            rwa_ccr_default=rwa_ccr_default,
            rwa_ccr_qccp_trade=rwa_ccr_qccp_trade,
            failed_trades_rwa=failed_trades_rwa,
            errors=[],
        )

PS1/26, paragraph 110A — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

apply_due_diligence_override — src/rwa_calc/engine/sa/rw_adjustments.py:422
@cites("PS1/26, paragraph 110A")
def apply_due_diligence_override(
    lf: pl.LazyFrame,
    config: CalculationConfig,
    *,
    errors: list[CalculationError] | None = None,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """Apply due diligence risk weight override (Basel 3.1 Art. 110A).

    Under Basel 3.1, firms must perform due diligence on all SA exposures.
    Where due diligence reveals that the risk weight does not adequately
    reflect the risk, the firm must apply a higher risk weight.

    The override only increases the risk weight — it can never reduce it.
    This is applied as the final risk weight modification before RWA
    calculation, after all standard RW determination, CRM, and currency
    mismatch adjustments.
    """
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
    if not resolved_pack.feature("sa_due_diligence_override"):
        return lf

    schema = lf.collect_schema()
    cols = schema.names()

    # Warn if due_diligence_performed column is absent under Basel 3.1
    if "due_diligence_performed" not in cols and errors is not None:
        errors.append(
            CalculationError(
                code=ERROR_DUE_DILIGENCE_NOT_PERFORMED,
                message=(
                    "Due diligence assessment status not provided "
                    "(due_diligence_performed column absent). "
                    "Art. 110A requires firms to perform due diligence "
                    "on all SA exposures to ensure risk weights "
                    "appropriately reflect exposure risk."
                ),
                severity=ErrorSeverity.WARNING,
                category=ErrorCategory.DATA_QUALITY,
                regulatory_reference="PRA PS1/26 Art. 110A",
                field_name="due_diligence_performed",
            )
        )

    # Apply override RW where provided and higher than calculated RW
    if "due_diligence_override_rw" not in cols:
        return lf

    override_applies = pl.col("due_diligence_override_rw").is_not_null() & (
        pl.col("due_diligence_override_rw") > pl.col("risk_weight")
    )

    return lf.with_columns(
        [
            pl.when(override_applies)
            .then(pl.col("due_diligence_override_rw"))
            .otherwise(pl.col("risk_weight"))
            .alias("risk_weight"),
            override_applies.alias("due_diligence_override_applied"),
        ]
    )

PS1/26, paragraph 111 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

_compute_ccf — src/rwa_calc/engine/ccf.py:419
    @cites("CRR Art. 111")
    @cites("PS1/26, paragraph 111")
    def _compute_ccf(
        self,
        exposures: pl.LazyFrame,
        config: CalculationConfig,
        *,
        pack: ResolvedRulepack | None = None,
    ) -> pl.LazyFrame:
        """Compute CCF based on risk type and approach.

        Determines SA and F-IRB CCFs from risk_type, then selects the final CCF
        based on the exposure's approach (SA/F-IRB/A-IRB).

        CRR Annex I / Art. 111(1) obs_product fill: before resolving CCFs, any row
        whose ``risk_type`` is null/empty has its ``risk_type`` resolved from the
        concrete ``obs_product`` key via ANNEX1_PRODUCT_RISK_TYPE (framework-
        invariant). An explicit ``risk_type`` always wins — the fill is gated on
        the existing value being null/empty.

        Applies the PRA PS1/26 Art. 111(1) Table A1 Row 4(b) override: a UK
        residential-property commitment (``is_uk_residential_mortgage_commitment``)
        gets a 50% SA CCF under Basel 3.1, except where the otherwise-resolved
        CCF is 10% (Row 6 UCC) or 100% (Row 2) — the Row 4(b) carve-out.
        """
        # CRR Annex I / Art. 111(1): resolve risk_type from the concrete OBS
        # product when (and only when) no explicit risk_type was supplied. Explicit
        # risk_type always wins; an unmapped/null product yields null and leaves
        # risk_type unchanged.
        risk_type_is_blank = (
            pl.col("risk_type").cast(pl.Utf8, strict=False).fill_null("").str.len_chars() == 0
        )
        product_risk_type = build_product_to_risk_type_expr("obs_product")
        exposures = exposures.with_columns(
            pl.when(risk_type_is_blank & product_risk_type.is_not_null())
            .then(product_risk_type)
            .otherwise(pl.col("risk_type"))
            .alias("risk_type"),
        )

        resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
        # S9c: the F-IRB-uses-SA-CCF routing gate (Art. 166C) reads the cited pack
        # Feature; sa_ccf_expression / _firb_ccf_for_col keep their is_basel_3_1 bool
        # plumbing params (Option B). All CCF VALUES stay static data-layer tables.
        is_b31 = resolved_pack.feature("firb_uses_sa_ccf")

        if is_b31:
            # Basel 3.1 Art. 166C: F-IRB uses SA CCFs (PRA PS1/26 Art. 111 Table A1)
            # FR=100%, MR=50%, MLR=20%, LR(UCC)=10%
            firb_ccf = sa_ccf_expression(is_basel_3_1=True)
        else:
            # CRR F-IRB: Art. 166(8)(d) -> 75% for credit lines / NIFs / RUFs
            # (is_obs_commitment=True); Art. 166(10) -> 100/50/20/0% fallback for
            # issued OBS items not in scope of paragraphs 1-8.
            firb_ccf = _firb_ccf_for_col("risk_type")

        exposures = exposures.with_columns(
            sa_ccf_expression(is_basel_3_1=is_b31).alias("_sa_ccf_from_risk_type"),
            firb_ccf.alias("_firb_ccf_from_risk_type"),
            (pl.col("nominal_amount").cast(pl.Float64, strict=False).abs() < 1e-10).alias(
                "_nominal_is_zero"
            ),
        )

        # CRR maturity-dependent OC override: under CRR, "other commitments" mapped
        # to MR (50%, >1yr) or MLR (20%, <=1yr). The sa_ccf_expression gives OC 50%
        # as the conservative default; override to 20% when remaining maturity <= 1yr.
        if not is_b31:
            normalized_rt = pl.col("risk_type").fill_null("").str.to_lowercase()
            is_oc = normalized_rt.is_in(["oc", "other_commit"])
            schema_names = exposures.collect_schema().names()
            if "maturity_date" in schema_names:
                is_short_maturity = pl.col("maturity_date").is_not_null() & (
                    (
                        pl.col("maturity_date").cast(pl.Date) - pl.lit(config.reporting_date)
                    ).dt.total_days()
                    <= _OC_SHORT_MATURITY_THRESHOLD_DAYS
                )
                exposures = exposures.with_columns(
                    pl.when(is_oc & is_short_maturity)
                    .then(pl.lit(_OC_SHORT_MATURITY_CCF))
                    .otherwise(pl.col("_sa_ccf_from_risk_type"))
                    .alias("_sa_ccf_from_risk_type"),
                )

        # PRA PS1/26 Art. 111(1) Table A1 Row 4(b): commitments to extend credit
        # secured by residential property attract a 50% CCF — "to the extent
        # that they are not subject to a conversion factor of 10% or 100%". When
        # the flag is set under Basel 3.1, override the otherwise-resolved SA CCF
        # to the MR / Row 4(b) rate (50%), unless that CCF is already 10% (Row 6
        # UCC) or 100% (Row 2), in which case the carve-out leaves it untouched.
        # No effect under CRR (Table A1 is Basel 3.1 only) — see the gate below.
        if is_b31:
            row_4b_ccf = _SA_CCF_B31_MAP["MR"]
            carve_out_ccfs = (_SA_CCF_B31_MAP["LR"], _SA_CCF_B31_MAP["FR"])
            is_resi_commitment = pl.col("is_uk_residential_mortgage_commitment").fill_null(False)
            sa_not_in_carve_out = ~pl.col("_sa_ccf_from_risk_type").is_in(carve_out_ccfs)
            exposures = exposures.with_columns(
                pl.when(is_resi_commitment & sa_not_in_carve_out)
                .then(pl.lit(row_4b_ccf))
                .otherwise(pl.col("_sa_ccf_from_risk_type"))
                .alias("_sa_ccf_from_risk_type"),
            )

            exposures = self._apply_purchased_receivable_ccf(exposures)

        # Art. 111(1)(c): commitment-to-issue lower-of rule.
        # When underlying_risk_type is specified, cap CCFs at the underlying item's CCF.
        # "the lower of (i) the CCF applicable to the underlying OBS item and
        #  (ii) the CCF applicable to the commitment type"
        has_underlying = pl.col("underlying_risk_type").fill_null("").str.len_chars() > 0
        underlying_sa = sa_ccf_expression("underlying_risk_type", is_basel_3_1=is_b31)
        exposures = exposures.with_columns(
            pl.when(has_underlying)
            .then(pl.min_horizontal(pl.col("_sa_ccf_from_risk_type"), underlying_sa))
            .otherwise(pl.col("_sa_ccf_from_risk_type"))
            .alias("_sa_ccf_from_risk_type"),
            pl.when(has_underlying)
            .then(
                pl.min_horizontal(
                    pl.col("_firb_ccf_from_risk_type"),
                    sa_ccf_expression("underlying_risk_type", is_basel_3_1=True)
                    if is_b31
                    else _firb_ccf_for_col("underlying_risk_type"),
                )
            )
            .otherwise(pl.col("_firb_ccf_from_risk_type"))
            .alias("_firb_ccf_from_risk_type"),
        )

        # A-IRB CCF: use modelled value, with Basel 3.1 restrictions
        ccf_modelled_expr = pl.col("ccf_modelled").cast(pl.Float64, strict=False)
        if is_b31:
            # Basel 3.1 Art. 166D(1)(a): own-estimate CCFs only for revolving
            # facilities whose SA CCF is not 100% (Table A1 Row 2 carve-out).
            # Non-revolving A-IRB must use SA CCFs from Table A1.
            # Revolving with SA CCF < 100%: own CCF with 50% SA floor (CRE32.27).
            airb_revolving_ccf = pl.max_horizontal(
                ccf_modelled_expr.fill_null(pl.col("_sa_ccf_from_risk_type")),
                pl.col("_sa_ccf_from_risk_type")
                * scalar_value(resolved_pack.scalar_param("airb_revolving_ccf_floor_multiplier")),
            )
            is_eligible_for_own_ccf = pl.col("is_revolving").fill_null(False) & (
                pl.col("_sa_ccf_from_risk_type") < 1.0
            )
            airb_ccf = (
                pl.when(is_eligible_for_own_ccf)
                .then(airb_revolving_ccf)
                .otherwise(pl.col("_sa_ccf_from_risk_type"))
            )
        else:
            airb_ccf = ccf_modelled_expr.fill_null(pl.col("_sa_ccf_from_risk_type"))

        # Select final CCF based on approach
        return exposures.with_columns(
            pl.when(pl.col("_nominal_is_zero"))
            .then(pl.lit(0.0))
            .when(pl.col("approach") == ApproachType.AIRB.value)
            .then(airb_ccf)
            .when(pl.col("approach") == ApproachType.FIRB.value)
            .then(pl.col("_firb_ccf_from_risk_type"))
            # CRR Art. 147(8): specialised-lending slotting is a corporate IRB
            # exposure, so its OBS EAD is governed by Art. 166(8) — the F-IRB CCF
            # (e.g. MR -> 75%), not the SA 50%. Under Basel 3.1, Art. 166C makes
            # F-IRB CCFs equal SA CCFs, so slotting stays on the SA path below.
            .when((pl.col("approach") == ApproachType.SLOTTING.value) & (not is_b31))
            .then(pl.col("_firb_ccf_from_risk_type"))
            .otherwise(pl.col("_sa_ccf_from_risk_type"))
            .alias("ccf"),
        )

PS1/26, paragraph 122 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

get_b31_combined_cqs_risk_weights — src/rwa_calc/engine/sa/b31_risk_weight_tables.py:453
@cites("PS1/26, paragraph 122")
def get_b31_combined_cqs_risk_weights() -> pl.DataFrame:
    """
    Get combined CQS-based risk weight table for Basel 3.1 joins.

    Uses Basel 3.1 corporate weights (CQS3=75%, CQS5=150%) and PRA PS1/26
    Art. 120 ECRA institution weights (CQS 2 = 30%).

    Returns:
        Combined DataFrame with columns: exposure_class, cqs, risk_weight
    """
    from rwa_calc.engine.sa.crr_risk_weight_tables import (
        _create_cgcb_df,
        _create_institution_df,
        _create_mdb_df,
        _create_pse_df,
        _create_rgla_df,
    )

    return pl.concat(
        [
            _create_cgcb_df().select(["exposure_class", "cqs", "risk_weight"]),
            _create_rgla_df().select(["exposure_class", "cqs", "risk_weight"]),
            _create_pse_df().select(["exposure_class", "cqs", "risk_weight"]),
            _create_mdb_df().select(["exposure_class", "cqs", "risk_weight"]),
            _create_institution_df(is_basel_3_1=True).select(
                ["exposure_class", "cqs", "risk_weight"]
            ),
            _create_b31_corporate_df().select(["exposure_class", "cqs", "risk_weight"]),
            _create_b31_covered_bond_df().select(["exposure_class", "cqs", "risk_weight"]),
        ]
    )
_prepare_risk_weight_lookup — src/rwa_calc/engine/sa/risk_weights.py:846
@cites("PS1/26, paragraph 139")
@cites("PS1/26, paragraph 122")
def _prepare_risk_weight_lookup(
    exposures: pl.LazyFrame,
    config: CalculationConfig,
    *,
    pack: ResolvedRulepack | None = None,
) -> tuple[pl.LazyFrame, pl.Expr, pl.Expr]:
    """Ensure required columns, classify for join, and attach CQS risk weights.

    Returns the exposures frame (with ``_lookup_class`` / ``_lookup_cqs`` /
    ``_upper_class`` / ``risk_weight`` columns added), the uppercase class
    expression reused by override chains, and the domestic-currency flag
    used for CGCB zero-weight treatment and sovereign-derived fallbacks.
    """
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack

    # CQS-based risk weight table — Basel 3.1 uses revised corporate weights
    if resolved_pack.feature("sa_revised_risk_weight_tables"):
        rw_table = get_b31_combined_cqs_risk_weights().lazy()
    else:
        rw_table = get_combined_cqs_risk_weights().lazy()

    # Fill missing optional columns (counterparty attrs, CRM outputs,
    # classifier flags, defensive input-schema fallbacks) from the
    # declarative contract.
    exposures = ensure_columns(exposures, SA_INPUT_CONTRACT)

    # Derive original_maturity_years from (maturity_date - value_date) when
    # not supplied directly. Required by Art. 116(3) PSE short-term,
    # Art. 120(2)/(2A) B31 rated institution short-term, Art. 121(3) unrated
    # institution short-term, and Art. 121(6) trade-goods sovereign floor
    # exception — all of which key off "original" maturity, not residual.
    derived_original = (
        pl.col("maturity_date").cast(pl.Int32) - pl.col("value_date").cast(pl.Int32)
    ).cast(pl.Float64) / 365.0
    exposures = exposures.with_columns(
        pl.when(pl.col("original_maturity_years").is_null())
        .then(derived_original)
        .otherwise(pl.col("original_maturity_years"))
        .alias("original_maturity_years")
    )
    schema = exposures.collect_schema()

    # CRR Art. 114(4)/(7): Domestic CGCB exposures -> 0% RW. Must compare
    # against the exposure's ORIGINAL denomination — the FX converter
    # overwrites `currency` with the reporting currency, so using it
    # directly would reject legitimate Art. 114(4) 0% treatment for any
    # non-base-currency exposure.
    ccy_expr = denomination_currency_expr(schema.names())
    is_uk_domestic = (pl.col("cp_country_code") == "GB") & (ccy_expr == "GBP")
    is_eu_domestic = build_eu_domestic_currency_expr("cp_country_code", ccy_expr)
    is_domestic_currency = is_uk_domestic | is_eu_domestic

    # Cache uppercase-class once and map detailed classes onto CQS-lookup
    # classes. Sentinel -1 for null CQS so the left join matches.
    upper = pl.col("exposure_class").str.to_uppercase()

    # CRR Art. 117(1) / PRA PS1/26 Art. 117(1)(a): non-named MDBs are treated
    # as institutions, so their primary CQS source is ``cp_institution_cqs``
    # (the MDB's own ECAI rating expressed as a CQS). When the exposure has
    # no top-level ``cqs`` (no rating attached at the rating-mapping stage)
    # but the counterparty carries an ``institution_cqs``, lift it into
    # ``cqs`` here so the downstream CQS-keyed branches and joins see it.
    # Named MDBs (mdb_named) bypass CQS entirely later — coalescing here is
    # harmless for them.
    is_mdb_class = upper == "MDB"
    # CRR Art. 107(2)(a): a non-qualifying CCP counterparty (entity_type "ccp"
    # demoted past the Art. 306(1) 2%/4% pin by cp_is_qccp=False) is treated as
    # an ordinary institution. Its own ECAI rating is carried on the synthetic
    # CCR row as ``cp_institution_cqs`` (the CCR adapter surfaces no top-level
    # ``cqs``), so lift it into ``cqs`` here — mirroring the MDB treatment —
    # so the Art. 120(1) Table 3 institution ladder resolves (e.g. CQS 2 -> 50%)
    # instead of the unrated 100% fallback. Scoped to ``ccp`` entity_type with a
    # null ``cqs`` so rated institutions and lending rows are untouched.
    is_non_qccp_institution = (pl.col("cp_entity_type").fill_null("") == "ccp") & ~pl.col(
        "cp_is_qccp"
    ).fill_null(True)
    exposures = exposures.with_columns(
        pl.when((is_mdb_class | is_non_qccp_institution) & pl.col("cqs").is_null())
        .then(pl.col("cp_institution_cqs"))
        .otherwise(pl.col("cqs"))
        .alias("cqs")
    )

    # PRA PS1/26 Art. 139(2B): for the purposes of Art. 122B(1) (the SA
    # specialised-lending routing), inferred / issuer-level (non-issue-specific)
    # ECAI assessments are disapplied. An SL exposure whose only resolved
    # external rating is not issue-specific must be treated as unrated, so we
    # null its CQS here. This re-routes it through the unrated SL override
    # (``b31_sa_sl_rw_expr``) instead of the rated-corporate CQS table. Scoped
    # to Basel 3.1 SL exposures only — ordinary rated corporates (Art. 122(2))
    # are untouched.
    if resolved_pack.feature("sa_sl_inferred_rating_disapplied"):
        is_sl_exposure = pl.col("sl_type").fill_null("").str.len_chars() > 0
        rating_not_issue_specific = (
            pl.col("external_rating_is_issue_specific").fill_null(True) == False  # noqa: E712
        )
        exposures = exposures.with_columns(
            pl.when(is_sl_exposure & rating_not_issue_specific)
            .then(pl.lit(None, dtype=pl.Int8))
            .otherwise(pl.col("cqs"))
            .alias("cqs")
        )

    exposures = exposures.with_columns(
        [
            pl.when(upper.str.contains("CENTRAL_GOVT", literal=True))
            .then(pl.lit("CENTRAL_GOVT_CENTRAL_BANK"))
            .when(upper == "RGLA")
            .then(pl.lit("RGLA"))
            .when(upper == "PSE")
            .then(pl.lit("PSE"))
            .when(upper == "MDB")
            .then(pl.lit("MDB"))
            .when(upper.str.contains("INSTITUTION", literal=True))
            .then(pl.lit("INSTITUTION"))
            .when(upper.str.contains("CORPORATE", literal=True))
            .then(pl.lit("CORPORATE"))
            # Rated SL uses corporate CQS table (Art. 122A(3))
            .when(upper.str.contains("SPECIALISED", literal=True))
            .then(pl.lit("CORPORATE"))
            .when(upper.str.contains("COVERED_BOND", literal=True))
            .then(pl.lit("COVERED_BOND"))
            .otherwise(upper)
            .alias("_lookup_class"),
            pl.col("cqs").fill_null(-1).cast(pl.Int8).alias("_lookup_cqs"),
            upper.alias("_upper_class"),
        ]
    )

    rw_table = rw_table.with_columns(
        pl.col("cqs").fill_null(-1).cast(pl.Int8).alias("cqs"),
    )
    exposures = exposures.join(
        rw_table.select(["exposure_class", "cqs", "risk_weight"]),
        left_on=["_lookup_class", "_lookup_cqs"],
        right_on=["exposure_class", "cqs"],
        how="left",
        suffix="_rw",
    )

    return exposures, pl.col("_upper_class"), is_domestic_currency

PS1/26, paragraph 123 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

_b31_append_retail_branches — src/rwa_calc/engine/sa/risk_weights.py:563
@cites("PS1/26, paragraph 123")
def _b31_append_retail_branches(chain: _RWChain, uc: pl.Expr) -> ChainedThen:
    """Append Basel 3.1 retail-class risk-weight branches (Art. 123).

    Covers the regulatory retail class only (uc contains "RETAIL"):
    - QRRE transactor: 45% (Art. 123(2)).
    - Payroll/pension loans: 35% (Art. 123(4)).
    - Non-regulatory retail (fails Art. 123A criteria): 100% (Art. 123(3)(c)).
    - Regulatory retail (non-mortgage): 75% flat.

    The SME-managed-as-retail and corporate-SME branches stay in the parent
    override (they gate on SME class membership rather than RETAIL).
    """
    return (
        # QRRE transactor: 45% (Art. 123(2)).
        chain.when(
            uc.str.contains("RETAIL", literal=True) & pl.col("is_qrre_transactor").fill_null(False)
        )
        .then(pl.lit(_SA_B31_RW["qrre_transactor"]))
        # Payroll/pension loans: 35% (Art. 123(4)).
        .when(uc.str.contains("RETAIL", literal=True) & pl.col("is_payroll_loan").fill_null(False))
        .then(pl.lit(_SA_B31_RW["payroll"]))
        # Non-regulatory retail (fails Art. 123A criteria): 100%.
        .when(
            uc.str.contains("RETAIL", literal=True)
            & (pl.col("qualifies_as_retail").fill_null(False) == False)  # noqa: E712
        )
        .then(pl.lit(_SA_B31_RW["non_reg_retail"]))
        # Regulatory retail (non-mortgage): 75% flat.
        .when(uc.str.contains("RETAIL", literal=True))
        .then(pl.lit(_SA_SHARED_RW["retail"]))
    )

PS1/26, paragraph 123A — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

_build_qualifies_as_retail_expr — src/rwa_calc/engine/stages/classify/attributes.py:539
@cites("CRR Art. 123")
@cites("PS1/26, paragraph 123A")
def _build_qualifies_as_retail_expr(
    config: CalculationConfig,
    max_retail_exposure: float,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.Expr:
    """Build qualifies_as_retail expression with Art. 123A enforcement.

    CRR: Threshold check only — aggregated exposure ≤ EUR 1m.

    Basel 3.1 Art. 123A adds two-path qualifying criteria:
    - Art. 123A(1)(a): SME entities (revenue > 0 and < GBP 44m) auto-qualify
      without needing pool management attestation.
    - Art. 123A(1)(b)(ii): an obligor's aggregate exposure must not exceed
      GBP 880k (threshold limb) AND no single obligor's aggregate exposure may
      exceed 0.2% of the total regulatory-retail portfolio (granularity limb,
      BCBS CRE20.66). Both limbs are Basel-3.1-only. The granularity limb is
      gated on ``config.enforce_retail_granularity`` (default True) so it can
      be suppressed under CRE20.66's national-discretion clause.
    - Art. 123A(1)(b)(iii): Non-SME entities must be managed as part of a
      retail pool (cp_is_managed_as_retail=True) to qualify.  Null values
      default to True for backward compatibility.

    References:
        PRA PS1/26 Art. 123A(1)(a)-(b), CRR Art. 123
    """
    # Hierarchy resolver now populates lending_group_adjusted_exposure with the
    # counterparty aggregate when no lending group exists, so the threshold
    # check is a single comparison across both cases.
    threshold_fail = pl.col("lending_group_adjusted_exposure") > max_retail_exposure

    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
    if not resolved_pack.feature("retail_art_123a_two_path_applicable"):
        # CRR: threshold check only
        return (
            pl.when(threshold_fail)
            .then(pl.lit(False))
            .otherwise(pl.lit(True))
            .alias("qualifies_as_retail")
        )

    # Basel 3.1: Art. 123A two-path qualifying criteria.
    # Art. 123A(1)(a): SME auto-qualification — counterparty meets the
    # Art. 4(1)(128D) SME size test (turnover < EUR 50m OR balance-sheet
    # total < EUR 43m when turnover null).
    is_sme_for_art_123a = is_sme_by_size_expr(config, pack=resolved_pack)

    # Art. 123A(1)(b)(ii) granularity limb (BCBS CRE20.66): no single obligor's
    # aggregate exposure may exceed 0.2% of the total regulatory-retail
    # portfolio. Candidate-retail rows are the entity-type RETAIL_OTHER
    # population (``_sa_class``); the denominator counts each obligor once by
    # dividing the per-obligor aggregate (``lending_group_adjusted_exposure``)
    # by the obligor's line-count, masking non-retail rows to 0, then summing.
    granularity_limit = float(_RETAIL_GRANULARITY_LIMIT)
    is_retail_candidate = pl.col("_sa_class") == ExposureClass.RETAIL_OTHER.value
    obligor_agg = pl.col("lending_group_adjusted_exposure")
    # Guard the nullable ``counterparty_reference`` partition: a null key would
    # otherwise pool all unmapped rows into a single bucket (see
    # ``partition_by_nullable`` / ``NULLABLE_PARTITION_KEYS``). Null-keyed rows
    # count as their own single-line obligor.
    obligor_line_count = partition_by_nullable(
        pl.len().over("counterparty_reference"),
        "counterparty_reference",
        pl.lit(1),
    )
    portfolio_total = (
        pl.when(is_retail_candidate).then(obligor_agg / obligor_line_count).otherwise(pl.lit(0.0))
    ).sum()
    granularity_fail = (
        is_retail_candidate
        & (portfolio_total > 0)
        & (obligor_agg / portfolio_total > granularity_limit)
    )

    expr = (
        pl.when(threshold_fail)
        .then(pl.lit(False))
        # Art. 123A(1)(a): SMEs auto-qualify — no condition 3 needed
        .when(is_sme_for_art_123a)
        .then(pl.lit(True))
    )

    # Art. 123A(1)(b)(ii) granularity limb: > 0.2% of the retail portfolio.
    # Gated on config.enforce_retail_granularity (default True) so the limb
    # can be suppressed where granularity is assessed by another method under
    # CRE20.66's national-discretion clause, or to isolate the other limbs.
    if config.enforce_retail_granularity:
        expr = expr.when(granularity_fail).then(pl.lit(False))

    # Art. 123A(1)(b)(iii): Non-SME must be managed as retail pool.
    # Null defaults to True (Art. 123A — documented KEEP: a null pool-
    # management flag preserves backward-compatible qualifying behaviour).
    expr = expr.when(
        pl.col("cp_is_managed_as_retail").fill_null(True) == False  # noqa: E712
    ).then(pl.lit(False))

    return expr.otherwise(pl.lit(True)).alias("qualifies_as_retail")

PS1/26, paragraph 123B — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

apply_currency_mismatch_multiplier — src/rwa_calc/engine/sa/rw_adjustments.py:293
@cites("PS1/26, paragraph 123B")
@cites("PS1/26, paragraph 123B.3")
def apply_currency_mismatch_multiplier(
    lf: pl.LazyFrame,
    config: CalculationConfig,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """Apply 1.5x RW multiplier for retail/RE currency mismatch (Basel 3.1 only).

    When the exposure currency differs from the borrower's income currency,
    a 1.5x multiplier is applied to the risk weight for retail and real estate
    exposure classes.

    Basel 3.1 Art. 123B / CRE20.93.

    Art. 123B(3) transitional: the multiplier is a Basel-3.1-only measure that
    commences on ``_B31_EFFECTIVE_DATE`` (1 January 2027). Reporting dates strictly
    before that fall under the pre-Basel-3.1 portfolio treatment and the frame is
    returned unchanged. The boundary date 1 January 2027 is in scope (strict ``<``).
    """
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
    if not resolved_pack.feature("sa_currency_mismatch_multiplier"):
        return lf

    # Art. 123B(3) transitional: pre-commencement reporting dates suppress the
    # multiplier entirely. Emit the reporting column as ``False`` (consistent with
    # the no-mismatch branch below) so downstream reporting always sees the flag.
    if config.reporting_date < _B31_EFFECTIVE_DATE:
        return lf.with_columns(pl.lit(False).alias("currency_mismatch_multiplier_applied"))

    schema = lf.collect_schema()
    cols = schema.names()

    # Need both exposure currency and borrower income currency
    income_col = (
        "cp_borrower_income_currency"
        if "cp_borrower_income_currency" in cols
        else "borrower_income_currency"
        if "borrower_income_currency" in cols
        else None
    )
    if income_col is None or "currency" not in cols:
        return lf

    # PRA PS1/26 Art. 123B: the 1.5x currency-mismatch multiplier is in scope
    # ONLY for retail (Art. 112(h)) and residential RE (Art. 112(i)) exposures.
    # Commercial RE (Art. 112(j) per Art. 124H/124I) and corporate are OUT of
    # scope. Use exact-match against ExposureClass enum string values rather
    # than substring matching to avoid COMMERCIAL_MORTGAGE matching "COMMERCIAL".
    is_retail_or_re = (
        pl.col("exposure_class")
        .fill_null("")
        .is_in(
            [
                "retail_other",
                "retail_qrre",
                "retail_mortgage",
                "residential_mortgage",
            ]
        )
    )

    has_mismatch = pl.col(income_col).is_not_null() & (pl.col(income_col) != pl.col("currency"))

    # Art. 123B(2) / CRE20.93: the 1.5x mismatch multiplier is suppressed when
    # the exposure is hedged against currency risk. A full hedge can be signalled
    # either by ``is_hedged=True`` OR by ``hedge_coverage_ratio >= 0.90`` (the
    # Art. 123B(2) partial-hedge coverage floor). Both columns default to their
    # "no hedge" sentinel when missing or null (False / 0.0).
    is_hedged_flag = pl.col("is_hedged").fill_null(False) if "is_hedged" in cols else pl.lit(False)
    # Art. 123B(2A): for revolving facilities the 90%-coverage test denominator is
    # the fully-drawn committed amount (the "instalment amount" = greater of the
    # contractual minimum and the fully-drawn contractual amount; leg (b) here,
    # there being no contractual-minimum field). The firm-supplied
    # ``hedge_coverage_ratio`` measures coverage of the CURRENT drawn balance, so
    # for revolving rows it is rescaled onto the full-draw base:
    #     full_draw_base     = max(drawn_amount, facility_limit)
    #     effective_coverage = (hedge_coverage_ratio * drawn_amount) / full_draw_base
    # Non-revolving rows are unchanged (effective_coverage = hedge_coverage_ratio).
    # is_revolving / facility_limit / drawn_amount may be absent on production SA
    # frames — default safely so the rescale is a no-op and legacy behaviour holds.
    if "hedge_coverage_ratio" in cols:
        raw_coverage = pl.col("hedge_coverage_ratio").fill_null(0.0)
        is_revolving_flag = (
            pl.col("is_revolving").fill_null(False) if "is_revolving" in cols else pl.lit(False)
        )
        drawn_amount = (
            pl.col("drawn_amount").fill_null(0.0) if "drawn_amount" in cols else pl.lit(0.0)
        )
        # Absent facility_limit -> use drawn_amount so full_draw_base == drawn_amount
        # and the rescale collapses to the legacy coverage ratio.
        facility_limit = (
            pl.col("facility_limit").fill_null(drawn_amount)
            if "facility_limit" in cols
            else drawn_amount
        )
        full_draw_base = pl.max_horizontal(drawn_amount, facility_limit)
        effective_coverage = (
            pl.when(is_revolving_flag & (full_draw_base > 0.0))
            .then((raw_coverage * drawn_amount) / full_draw_base)
            .otherwise(raw_coverage)
        )
        hedge_coverage_ok = effective_coverage >= _SA_B31_RW["currency_mismatch_hedge_floor"]
    else:
        hedge_coverage_ok = pl.lit(False)
    waive_expr = is_hedged_flag | hedge_coverage_ok

    mismatch_applies = is_retail_or_re & has_mismatch & ~waive_expr

    return lf.with_columns(
        [
            # Snapshot pre-multiplier RW for audit/reporting (mirrors the
            # pre_fcsm_risk_weight pattern). For non-mismatch rows this equals
            # the unchanged risk_weight; CR5 buckets EAD on this column.
            pl.col("risk_weight").alias("risk_weight_pre_currency_mismatch"),
            pl.when(mismatch_applies)
            .then(
                (pl.col("risk_weight") * _SA_B31_RW["currency_mismatch_multiplier"]).clip(
                    upper_bound=pl.lit(_SA_B31_RW["currency_mismatch_cap"])
                )
            )
            .otherwise(pl.col("risk_weight"))
            .alias("risk_weight"),
            mismatch_applies.alias("currency_mismatch_multiplier_applied"),
        ]
    )

PS1/26, paragraph 123B.3 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

apply_currency_mismatch_multiplier — src/rwa_calc/engine/sa/rw_adjustments.py:294
@cites("PS1/26, paragraph 123B")
@cites("PS1/26, paragraph 123B.3")
def apply_currency_mismatch_multiplier(
    lf: pl.LazyFrame,
    config: CalculationConfig,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """Apply 1.5x RW multiplier for retail/RE currency mismatch (Basel 3.1 only).

    When the exposure currency differs from the borrower's income currency,
    a 1.5x multiplier is applied to the risk weight for retail and real estate
    exposure classes.

    Basel 3.1 Art. 123B / CRE20.93.

    Art. 123B(3) transitional: the multiplier is a Basel-3.1-only measure that
    commences on ``_B31_EFFECTIVE_DATE`` (1 January 2027). Reporting dates strictly
    before that fall under the pre-Basel-3.1 portfolio treatment and the frame is
    returned unchanged. The boundary date 1 January 2027 is in scope (strict ``<``).
    """
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
    if not resolved_pack.feature("sa_currency_mismatch_multiplier"):
        return lf

    # Art. 123B(3) transitional: pre-commencement reporting dates suppress the
    # multiplier entirely. Emit the reporting column as ``False`` (consistent with
    # the no-mismatch branch below) so downstream reporting always sees the flag.
    if config.reporting_date < _B31_EFFECTIVE_DATE:
        return lf.with_columns(pl.lit(False).alias("currency_mismatch_multiplier_applied"))

    schema = lf.collect_schema()
    cols = schema.names()

    # Need both exposure currency and borrower income currency
    income_col = (
        "cp_borrower_income_currency"
        if "cp_borrower_income_currency" in cols
        else "borrower_income_currency"
        if "borrower_income_currency" in cols
        else None
    )
    if income_col is None or "currency" not in cols:
        return lf

    # PRA PS1/26 Art. 123B: the 1.5x currency-mismatch multiplier is in scope
    # ONLY for retail (Art. 112(h)) and residential RE (Art. 112(i)) exposures.
    # Commercial RE (Art. 112(j) per Art. 124H/124I) and corporate are OUT of
    # scope. Use exact-match against ExposureClass enum string values rather
    # than substring matching to avoid COMMERCIAL_MORTGAGE matching "COMMERCIAL".
    is_retail_or_re = (
        pl.col("exposure_class")
        .fill_null("")
        .is_in(
            [
                "retail_other",
                "retail_qrre",
                "retail_mortgage",
                "residential_mortgage",
            ]
        )
    )

    has_mismatch = pl.col(income_col).is_not_null() & (pl.col(income_col) != pl.col("currency"))

    # Art. 123B(2) / CRE20.93: the 1.5x mismatch multiplier is suppressed when
    # the exposure is hedged against currency risk. A full hedge can be signalled
    # either by ``is_hedged=True`` OR by ``hedge_coverage_ratio >= 0.90`` (the
    # Art. 123B(2) partial-hedge coverage floor). Both columns default to their
    # "no hedge" sentinel when missing or null (False / 0.0).
    is_hedged_flag = pl.col("is_hedged").fill_null(False) if "is_hedged" in cols else pl.lit(False)
    # Art. 123B(2A): for revolving facilities the 90%-coverage test denominator is
    # the fully-drawn committed amount (the "instalment amount" = greater of the
    # contractual minimum and the fully-drawn contractual amount; leg (b) here,
    # there being no contractual-minimum field). The firm-supplied
    # ``hedge_coverage_ratio`` measures coverage of the CURRENT drawn balance, so
    # for revolving rows it is rescaled onto the full-draw base:
    #     full_draw_base     = max(drawn_amount, facility_limit)
    #     effective_coverage = (hedge_coverage_ratio * drawn_amount) / full_draw_base
    # Non-revolving rows are unchanged (effective_coverage = hedge_coverage_ratio).
    # is_revolving / facility_limit / drawn_amount may be absent on production SA
    # frames — default safely so the rescale is a no-op and legacy behaviour holds.
    if "hedge_coverage_ratio" in cols:
        raw_coverage = pl.col("hedge_coverage_ratio").fill_null(0.0)
        is_revolving_flag = (
            pl.col("is_revolving").fill_null(False) if "is_revolving" in cols else pl.lit(False)
        )
        drawn_amount = (
            pl.col("drawn_amount").fill_null(0.0) if "drawn_amount" in cols else pl.lit(0.0)
        )
        # Absent facility_limit -> use drawn_amount so full_draw_base == drawn_amount
        # and the rescale collapses to the legacy coverage ratio.
        facility_limit = (
            pl.col("facility_limit").fill_null(drawn_amount)
            if "facility_limit" in cols
            else drawn_amount
        )
        full_draw_base = pl.max_horizontal(drawn_amount, facility_limit)
        effective_coverage = (
            pl.when(is_revolving_flag & (full_draw_base > 0.0))
            .then((raw_coverage * drawn_amount) / full_draw_base)
            .otherwise(raw_coverage)
        )
        hedge_coverage_ok = effective_coverage >= _SA_B31_RW["currency_mismatch_hedge_floor"]
    else:
        hedge_coverage_ok = pl.lit(False)
    waive_expr = is_hedged_flag | hedge_coverage_ok

    mismatch_applies = is_retail_or_re & has_mismatch & ~waive_expr

    return lf.with_columns(
        [
            # Snapshot pre-multiplier RW for audit/reporting (mirrors the
            # pre_fcsm_risk_weight pattern). For non-mismatch rows this equals
            # the unchanged risk_weight; CR5 buckets EAD on this column.
            pl.col("risk_weight").alias("risk_weight_pre_currency_mismatch"),
            pl.when(mismatch_applies)
            .then(
                (pl.col("risk_weight") * _SA_B31_RW["currency_mismatch_multiplier"]).clip(
                    upper_bound=pl.lit(_SA_B31_RW["currency_mismatch_cap"])
                )
            )
            .otherwise(pl.col("risk_weight"))
            .alias("risk_weight"),
            mismatch_applies.alias("currency_mismatch_multiplier_applied"),
        ]
    )

PS1/26, paragraph 124 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

_b31_append_real_estate_branches — src/rwa_calc/engine/sa/risk_weights.py:597
@cites("PS1/26, paragraph 124")
def _b31_append_real_estate_branches(chain: _RWChain, uc: pl.Expr) -> ChainedThen:
    """Append Basel 3.1 real-estate branches (ADC / other-RE / CRE / resi)."""
    is_re_class = (
        _is_commercial_re_class(uc)
        | _is_residential_re_class(uc)
        | (pl.col("property_type").fill_null("").is_in(["residential", "commercial"]))
    )
    is_non_qualifying = pl.col("is_qualifying_re").fill_null(True) == False  # noqa: E712
    return (
        chain.when(pl.col("is_adc").fill_null(False))
        .then(b31_adc_rw_expr())
        # Art. 124J: non-qualifying RE that fails Art. 124A criteria.
        # Null is_qualifying_re defaults to qualifying — backward compatible.
        .when(is_non_qualifying & is_re_class)
        .then(b31_other_re_rw_expr("_cqs_risk_weight"))
        # Commercial RE must precede residential — see _is_commercial_re_class.
        .when(_is_commercial_re_class(uc))
        .then(b31_commercial_rw_expr("_cqs_risk_weight"))
        .when(_is_residential_re_class(uc))
        .then(b31_residential_rw_expr("_cqs_risk_weight"))
    )

PS1/26, paragraph 124.4 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

_re_split_per_component_eligibility — src/rwa_calc/engine/stages/re_split/flagging.py:230
@cites("PS1/26, paragraph 124.4")
def _re_split_per_component_eligibility(
    primitives: dict[str, pl.Expr],
    gates: dict[str, pl.Expr],
    config: CalculationConfig,
    *,
    pack: ResolvedRulepack | None = None,
) -> dict[str, pl.Expr]:
    """Build per-component eligibility flags for the RE loan splitter.

    Implements the PRA PS1/26 Art. 124(4) mixed-RE rule (and CRR Art.
    124(1) "any part of an exposure" wording): each property component
    is evaluated against its own regime gate. Under CRR, CRE additionally
    requires the rental-coverage test. ``is_mixed`` flags rows where
    both components are eligible — the splitter materialises one secured
    row per eligible component plus a single residual.

    Art. 124(4) all-or-nothing qualifying gate (Basel 3.1 only): the
    preferential Art. 124F-124I tables apply to a mixed-RE exposure only
    when BOTH components separately qualify under Art. 124A. If either
    component fails (``re_collateral_non_qualifying``), ``force_other_re``
    fires and the splitter routes BOTH secured rows through Art. 124J
    (Other RE) — no partial preference. CRR has no Art. 124(4) limb, so
    the gate is suppressed on the CRR path.
    """
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
    rre_eligible = gates["is_candidate"] & primitives["has_rre"]
    if resolved_pack.feature("sa_re_split_cre_rental_coverage_required"):
        # CRR Art. 126(2)(d): CRE eligibility additionally requires the
        # rental-coverage test (>= 1.5x interest).
        cre_eligible = (
            gates["is_candidate"] & primitives["has_cre"] & primitives["cre_rental_coverage_met"]
        )
    else:
        # PS1/26 Art. 124H: Basel 3.1 removes the CRE rental-coverage requirement.
        cre_eligible = gates["is_candidate"] & primitives["has_cre"]
    is_mixed = rre_eligible & cre_eligible
    # PS1/26 Art. 124(4) all-or-nothing mixed-RE gate — no CRR equivalent.
    force_other_re = (
        is_mixed & primitives["re_collateral_non_qualifying"]
        if resolved_pack.feature("sa_re_split_art_124_4_all_or_nothing")
        else pl.lit(False)
    )
    return {
        "rre_eligible": rre_eligible,
        "cre_eligible": cre_eligible,
        "is_mixed": is_mixed,
        "force_other_re": force_other_re,
    }

PS1/26, paragraph 124E — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

_build_has_income_cover_expr — src/rwa_calc/engine/stages/classify/attributes.py:456
@cites("PS1/26, paragraph 124E")
def _build_has_income_cover_expr() -> pl.Expr:
    """Build ``has_income_cover`` with the Art. 124E three-property re-route.

    PRA PS1/26 Art. 124E(1)(b) restricts the owner-occupied preferential
    residential treatment (Art. 124F loan-split / Art. 124L) to natural-person
    borrowers whose total residential RE exposure is secured on no more than
    three residential properties. When the count strictly exceeds three
    (``cp_qualifying_property_count > _RRE_THREE_PROPERTY_LIMIT``), the
    exposure is materially dependent on property cash flows (Art. 124E(2))
    and routes to the income-producing whole-loan track (Art. 124G).

    Boundary: the comparison is strict ``> 3`` — count=3 stays owner-occupied,
    count=4 re-routes.

    Coalesce precedence: any explicit upstream ``has_income_cover=True`` (set
    from collateral ``is_income_producing`` in the hierarchy stage) wins, so a
    caller-supplied income flag is never overridden by a low property count.

    Returns a ``pl.Expr`` aliased ``has_income_cover`` (Boolean). The
    gating columns are sealed-lookup joins (``cp_qualifying_property_count``
    / ``cp_is_natural_person``) — always present; null counts never breach
    the limit and null natural-person flags fail the gate.
    """
    is_natural_person = pl.col("cp_is_natural_person").fill_null(False)
    # Strict > 3: count=3 stays owner-occupied; count=4 re-routes (Art. 124E(1)(b)).
    breaches_limit = pl.col("cp_qualifying_property_count") > _RRE_THREE_PROPERTY_LIMIT
    materially_dependent = is_natural_person & breaches_limit

    explicit = pl.col("has_income_cover").fill_null(False)
    # Explicit upstream income flag wins; otherwise the derived re-route applies.
    return (explicit | materially_dependent).alias("has_income_cover")

PS1/26, paragraph 124F — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

split — src/rwa_calc/engine/stages/re_split/splitter.py:173
    @cites("CRR Art. 125")
    @cites("CRR Art. 126")
    @cites("PS1/26, paragraph 124F")
    def split(
        self,
        data: CRMAdjustedBundle,
        config: CalculationConfig,
        *,
        pack: ResolvedRulepack | None = None,
    ) -> CRMAdjustedBundle:
        """Apply RE loan-splitting to candidate rows.

        See module docstring for the regime-specific decision matrix.
        """
        # S9g: the RE-split regime gate reads the cited pack Feature; the split
        # parameter VALUES (LTV caps / RW) stay in data/tables/re_split_parameters.py,
        # and re_split_parameters / _split_unified_frame keep their is_basel_3_1 bool
        # plumbing params (Option B). One read feeds both the params lookup and the
        # allocation control flow.
        resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
        is_b31 = resolved_pack.feature("sa_re_split_revised_parameters")
        params = re_split_parameters(is_basel_3_1=is_b31)
        rrep = params["residential"]
        crep = params["commercial"]

        unified, audit, errors = _split_unified_frame(
            data.exposures,
            rrep=rrep,
            crep=crep,
            is_basel_3_1=is_b31,
        )

        # Producer seal (Phase 3): pure plan-level conform + brand — the
        # orchestrator materialises and re-seals at the re_split_exit stage
        # edge. Contract selected by the input frame's brand (CCR runs carry
        # the SA-CCR provenance columns through the split).
        exit_edge = (
            RE_SPLIT_EXIT_CCR_EDGE
            if sealed_edge_of(data.exposures) == "crm_exit_ccr"
            else RE_SPLIT_EXIT_EDGE
        )

        return CRMAdjustedBundle(
            exposures=seal(unified, exit_edge),
            equity_exposures=data.equity_exposures,
            ciu_holdings=data.ciu_holdings,
            collateral_allocation=data.collateral_allocation,
            collateral_link_allocation=data.collateral_link_allocation,
            re_split_audit=audit,
            securitisation_audit=data.securitisation_audit,
            crm_errors=list(data.crm_errors) + errors,
        )

PS1/26, paragraph 127 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

_apply_defaulted_risk_weight — src/rwa_calc/engine/sa/risk_weights.py:1449
@cites("CRR Art. 127")
@cites("PS1/26, paragraph 127")
def _apply_defaulted_risk_weight(
    exposures: pl.LazyFrame,
    config: CalculationConfig,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """Apply Art. 127 defaulted risk weight to the full post-CRM exposure.

    PS1/26 Art. 127(1)-(2) assign 100% or 150% to the part of a defaulted
    exposure that is not secured by recognised collateral or covered by
    recognised unfunded credit protection, where the unsecured part is
    determined by the CRM method the institution applies (Art. 191A(2)).

    Under the Financial Collateral Comprehensive Method (the default for
    SA), eligible financial collateral has already reduced ``ead_final``
    in the CRM stage and eligible residential/commercial real estate has
    been routed via class reclassification — so ``ead_final`` already
    represents the unsecured value and Art. 127(1) applies to it flat.
    FCSM (Simple Method) is handled downstream by
    ``apply_fcsm_rw_substitution``, which blends the defaulted RW with
    the collateral RW per the substitution rule.

    Basel 3.1 Art. 127(3) / CRE20.88 exception: a residential RE default
    that is not materially dependent on cash-flows of the property is
    assigned 100% flat, regardless of provisions.

    References:
    - PS1/26 Art. 127(1): unsecured part 100%/150% by provision coverage
    - PS1/26 Art. 127(2): unsecured part determined by the CRM method
    - PS1/26 Art. 127(3) / CRE20.88: RESI RE non-income flat 100%
    - CRR Art. 127(1)-(2): CRR predecessor (pre-provision denominator)
    """
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
    _uc = pl.col("exposure_class").fill_null("").str.to_uppercase()
    ead = pl.col("ead_final")

    if resolved_pack.feature("sa_revised_defaulted_treatment"):
        # B31 RESI RE non-income-dependent: 100% flat (Art. 127(3) / CRE20.88).
        is_resi_re_non_income = (
            _is_residential_re_class(_uc)
            & ~_is_commercial_re_class(_uc)
            & ~pl.col("has_income_cover").fill_null(False)
        )

        # PS1/26 Art. 127(1): denominator is "the outstanding amount of the
        # item or facility" — gross outstanding (pre-CRM, pre-provision).
        # Reconstruct from ead_gross (post-CCF, post-provision, pre-CRM) plus
        # provision_deducted.
        gross_outstanding = pl.col("ead_gross") + pl.col("provision_deducted")
        provision_rw = (
            pl.when(
                pl.col("provision_allocated")
                >= _SA_B31_RW["defaulted_threshold"] * gross_outstanding
            )
            .then(pl.lit(_SA_B31_RW["defaulted_high"]))
            .otherwise(pl.lit(_SA_B31_RW["defaulted_low"]))
        )

        defaulted_rw = (
            pl.when(is_resi_re_non_income)
            .then(pl.lit(_SA_B31_RW["defaulted_resi_re_non_income"]))
            .otherwise(provision_rw)
        )
    else:
        # CRR Art. 127(1): denominator is the pre-provision exposure value
        # (ead_final is post-provision, so add provision_deducted back).
        unsecured_pre_prov = ead + pl.col("provision_deducted")
        defaulted_rw = (
            pl.when(
                pl.col("provision_allocated")
                >= _SA_CRR_RW["defaulted_threshold"] * unsecured_pre_prov
            )
            .then(pl.lit(_SA_CRR_RW["defaulted_high"]))
            .otherwise(pl.lit(_SA_CRR_RW["defaulted_low"]))
        )

    # Art. 128 (HIGH_RISK) takes precedence per Table A2 priority 4 > 5
    is_defaulted = pl.col("is_defaulted").fill_null(False) & (_uc != "HIGH_RISK")

    return exposures.with_columns(
        pl.when(is_defaulted)
        .then(defaulted_rw)
        .otherwise(pl.col("risk_weight"))
        .alias("risk_weight")
    )

PS1/26, paragraph 128 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

_b31_append_high_risk_branch — src/rwa_calc/engine/sa/risk_weights.py:548
@cites("PS1/26, paragraph 128")
def _b31_append_high_risk_branch(chain: _RWChain, uc: pl.Expr) -> ChainedThen:
    """Append Basel 3.1 Art. 128 high-risk items branch (150% flat).

    Items associated with particularly high risk — venture capital, private
    equity, speculative immovable property financing, and other
    PRA-designated high-risk items — receive a 150% risk weight under
    PRA PS1/26 Art. 128. CRR has no parallel branch: Art. 128 was omitted
    from UK CRR by SI 2021/1078 reg. 6(3)(a) effective 1 January 2022, so
    HIGH_RISK exposures fall through to the residual OTHER class (100%)
    on the CRR path.
    """
    return chain.when(uc == "HIGH_RISK").then(pl.lit(_SA_B31_RW["high_risk"]))

PS1/26, paragraph 129 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

_create_b31_covered_bond_df — src/rwa_calc/engine/sa/b31_risk_weight_tables.py:431
@cites("PS1/26, paragraph 129")
def _create_b31_covered_bond_df() -> pl.DataFrame:
    """Create Basel 3.1 covered bond risk weight lookup DataFrame.

    PRA PS1/26 Art. 129(4) Table 7 — identical to CRR Table 6A.
    """
    return _build_int_cqs_rw_df(
        B31_COVERED_BOND_RISK_WEIGHTS,
        "COVERED_BOND",
        order=_B31_CQS_RATED_ORDER,
    )
_b31_unrated_cb_rw_expr — src/rwa_calc/engine/sa/risk_weights.py:479
@cites("CRR Art. 129")
@cites("PS1/26, paragraph 129")
def _b31_unrated_cb_rw_expr() -> pl.Expr:
    """Build Polars expression for B31 Art. 129(5) unrated covered bond RW derivation.

    Derives covered bond RW from the issuing institution's senior unsecured RW,
    which can come from either source:
      1. ECRA (rated institution): cp_institution_cqs → institution RW → CB RW
      2. SCRA (unrated institution): cp_scra_grade → CB RW

    Art. 129(5) operates on the resulting institution RW regardless of source
    (ECRA or SCRA). The ECRA path is checked first; if cp_institution_cqs is
    null, falls back to the SCRA path.

    References:
        PRA PS1/26 Art. 120 Table 3 ECRA: Institution risk weights (CQS 2 = 30%)
        PRA PS1/26 Art. 120A: SCRA institution risk weights
        PRA PS1/26 Art. 129(5): Unrated covered bond derivation from institution RW
    """
    inst_table = INSTITUTION_RISK_WEIGHTS_B31_ECRA
    cqs_to_cb_rw: dict[int, float] = {}
    for cqs_val in [CQS.CQS1, CQS.CQS2, CQS.CQS3, CQS.CQS4, CQS.CQS5, CQS.CQS6]:
        inst_rw = inst_table[cqs_val]
        cb_rw = COVERED_BOND_UNRATED_DERIVATION_B31[inst_rw]
        cqs_to_cb_rw[int(cqs_val)] = float(cb_rw)

    # Build when/then: ECRA first (cp_institution_cqs)
    expr = pl.when(pl.col("cp_institution_cqs") == 1).then(pl.lit(cqs_to_cb_rw[1]))
    for cqs_int in [2, 3, 4, 5, 6]:
        expr = expr.when(pl.col("cp_institution_cqs") == cqs_int).then(
            pl.lit(cqs_to_cb_rw[cqs_int])
        )
    # SCRA fallback (cp_scra_grade) for unrated issuers
    for grade, cb_rw in B31_COVERED_BOND_UNRATED_FROM_SCRA.items():
        expr = expr.when(pl.col("cp_scra_grade") == grade).then(pl.lit(float(cb_rw)))
    # Conservative default: Grade C equivalent (B31_COVERED_BOND_UNRATED_FROM_SCRA["C"])
    return expr.otherwise(pl.lit(_SA_B31_RW["unrated_cb_default"]))

PS1/26, paragraph 132 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

_append_ciu_branches — src/rwa_calc/engine/equity/calculator.py:119
@cites("PS1/26, paragraph 132")
def _append_ciu_branches(chain: pl.Expr) -> ChainedThen:
    """Append CIU approach-aware risk weight branches to a when/then chain (Art. 132-132C).

    Covers: fallback (1,250%), mandate_based (ciu_mandate_rw x1.2 if third-party),
    look_through (ciu_look_through_rw), and unclassified CIU (1,250% default).
    """
    _is_ciu = pl.col("equity_type").str.to_lowercase() == "ciu"
    # The piped-in chain is an in-progress when/then; narrow for the checker.
    then_chain = cast("Then | ChainedThen", chain)
    return (
        then_chain.when(_is_ciu & (pl.col("ciu_approach") == "fallback"))
        .then(pl.lit(CIU_FALLBACK_RW))
        .when(_is_ciu & (pl.col("ciu_approach") == "mandate_based"))
        .then(
            pl.col("ciu_mandate_rw").fill_null(CIU_FALLBACK_RW)
            * pl.when(pl.col("ciu_third_party_calc").fill_null(False))
            .then(pl.lit(_CIU_THIRD_PARTY_MULTIPLIER))
            .otherwise(pl.lit(_CIU_INTERNAL_MULTIPLIER))
        )
        .when(_is_ciu & (pl.col("ciu_approach") == "look_through"))
        .then(pl.col("ciu_look_through_rw").fill_null(CIU_FALLBACK_RW))
        .when(_is_ciu)
        .then(pl.lit(CIU_FALLBACK_RW))
    )

PS1/26, paragraph 133 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

_apply_b31_equity_weights_sa — src/rwa_calc/engine/equity/calculator.py:588
    @cites("PS1/26, paragraph 133")
    def _apply_b31_equity_weights_sa(
        self,
        exposures: pl.LazyFrame,
        config: CalculationConfig,
    ) -> pl.LazyFrame:
        """
        Apply Basel 3.1 PRA PS1/26 Art. 133 SA equity risk weights.

        Risk weights (in priority order per classification decision tree):
        1. Central bank: 0%  (sovereign treatment)
        2. Subordinated debt / non-equity own funds: 150%  (Art. 133(5))
        3. Speculative / higher risk: 400%  (Art. 133(4))
        4. Higher-risk test (Art. 133(4) + Glossary p.5), for any equity that
           is not central-bank / subordinated-debt / CIU / government-supported:
           - unlisted (NOT is_exchange_traded) AND business_age_years < 5.0
             (or null, treated conservatively) -> 400% (higher-risk)
           - otherwise -> falls through to standard 250% (Art. 133(3))
        5. CIU: approach-dependent  (Art. 132-132C)
           - CIU fallback: 1,250%  (Art. 132(2))
        6. All other standard equity (incl. government-supported, listed, and
           long-established/exchange-traded equity): 250%  (Art. 133(3))

        Note: B31 Art. 133(6) is an exclusion clause (own funds deductions,
        Art. 89(3), Art. 48(4)) — NOT a risk weight assignment. CRR's 100%
        legislative equity (Art. 133(3)(c)) has no equivalent in B31.
        """
        # Art. 133(4) / Glossary p.5 higher-risk test — unlisted equity whose
        # underlying business has existed < 5 years. Two routings combine:
        #
        #   (1) PE/VC legacy routing: unlisted PE/VC with business age < 5y OR
        #       unknown -> 400%. Null/missing age is treated conservatively as
        #       <5y (a firm cannot claim the long-established carve-out without
        #       evidence of business age >= 5), preserving the prior behaviour
        #       for callers that supply no business_age_years.
        #
        #   (2) Generalised routing: ANY other equity that is NOT
        #       central-bank (0%), subordinated-debt (150%, Art. 133(5)), CIU
        #       (Art. 132 look-through/mandate/fallback) or government-supported
        #       (standard 250%, Art. 133(3)) is higher-risk only when it has an
        #       *evidenced* young business age (non-null AND < 5.0). Absent age
        #       data, such equity stays at the standard 250% — listed/unlisted/
        #       other without business-age evidence is not uplifted.
        schema_names = exposures.collect_schema().names()
        is_pe_or_pe_div = (
            pl.col("equity_type")
            .str.to_lowercase()
            .is_in([EquityType.PRIVATE_EQUITY, EquityType.PRIVATE_EQUITY_DIVERSIFIED])
        )
        is_dedicated_treatment = (
            pl.col("equity_type")
            .str.to_lowercase()
            .is_in(
                [
                    EquityType.CENTRAL_BANK,
                    EquityType.SUBORDINATED_DEBT,
                    EquityType.CIU,
                    EquityType.GOVERNMENT_SUPPORTED,
                ]
            )
        )
        is_unlisted = (
            ~pl.col("is_exchange_traded").fill_null(False)
            if "is_exchange_traded" in schema_names
            else pl.lit(True)
        )
        has_age = "business_age_years" in schema_names
        is_young_or_unknown = (
            pl.col("business_age_years").is_null() | (pl.col("business_age_years") < 5.0)
            if has_age
            else pl.lit(True)
        )
        is_young_evidenced = (
            pl.col("business_age_years").is_not_null() & (pl.col("business_age_years") < 5.0)
            if has_age
            else pl.lit(False)
        )
        is_higher_risk = is_unlisted & (
            (is_pe_or_pe_div & is_young_or_unknown) | (~is_dedicated_treatment & is_young_evidenced)
        )

        return exposures.with_columns(
            [
                pl.when(pl.col("equity_type").str.to_lowercase() == "central_bank")
                .then(pl.lit(_B31_SA_RW[EquityType.CENTRAL_BANK]))
                # Art. 133(5): subordinated debt / non-equity own funds = 150%
                .when(pl.col("equity_type").str.to_lowercase() == "subordinated_debt")
                .then(pl.lit(_B31_SA_RW[EquityType.SUBORDINATED_DEBT]))
                .when(pl.col("is_speculative") == True)  # noqa: E712
                .then(pl.lit(_B31_SA_RW[EquityType.SPECULATIVE]))
                .when(pl.col("equity_type").str.to_lowercase() == "speculative")
                .then(pl.lit(_B31_SA_RW[EquityType.SPECULATIVE]))
                # Art. 133(4) + Glossary p.5: unlisted equity with business age
                # < 5y (or unknown) is higher-risk (400%); long-established or
                # exchange-traded equity falls through to standard 250%.
                .when(is_higher_risk)
                .then(pl.lit(_B31_SA_RW[EquityType.PRIVATE_EQUITY]))
                # CIU: approach-aware risk weights (Art. 132-132C)
                .pipe(_append_ciu_branches)
                .otherwise(pl.lit(_B31_SA_RW[EquityType.OTHER]))
                .alias("risk_weight"),
            ]
        )

PS1/26, paragraph 139 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

_prepare_risk_weight_lookup — src/rwa_calc/engine/sa/risk_weights.py:845
@cites("PS1/26, paragraph 139")
@cites("PS1/26, paragraph 122")
def _prepare_risk_weight_lookup(
    exposures: pl.LazyFrame,
    config: CalculationConfig,
    *,
    pack: ResolvedRulepack | None = None,
) -> tuple[pl.LazyFrame, pl.Expr, pl.Expr]:
    """Ensure required columns, classify for join, and attach CQS risk weights.

    Returns the exposures frame (with ``_lookup_class`` / ``_lookup_cqs`` /
    ``_upper_class`` / ``risk_weight`` columns added), the uppercase class
    expression reused by override chains, and the domestic-currency flag
    used for CGCB zero-weight treatment and sovereign-derived fallbacks.
    """
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack

    # CQS-based risk weight table — Basel 3.1 uses revised corporate weights
    if resolved_pack.feature("sa_revised_risk_weight_tables"):
        rw_table = get_b31_combined_cqs_risk_weights().lazy()
    else:
        rw_table = get_combined_cqs_risk_weights().lazy()

    # Fill missing optional columns (counterparty attrs, CRM outputs,
    # classifier flags, defensive input-schema fallbacks) from the
    # declarative contract.
    exposures = ensure_columns(exposures, SA_INPUT_CONTRACT)

    # Derive original_maturity_years from (maturity_date - value_date) when
    # not supplied directly. Required by Art. 116(3) PSE short-term,
    # Art. 120(2)/(2A) B31 rated institution short-term, Art. 121(3) unrated
    # institution short-term, and Art. 121(6) trade-goods sovereign floor
    # exception — all of which key off "original" maturity, not residual.
    derived_original = (
        pl.col("maturity_date").cast(pl.Int32) - pl.col("value_date").cast(pl.Int32)
    ).cast(pl.Float64) / 365.0
    exposures = exposures.with_columns(
        pl.when(pl.col("original_maturity_years").is_null())
        .then(derived_original)
        .otherwise(pl.col("original_maturity_years"))
        .alias("original_maturity_years")
    )
    schema = exposures.collect_schema()

    # CRR Art. 114(4)/(7): Domestic CGCB exposures -> 0% RW. Must compare
    # against the exposure's ORIGINAL denomination — the FX converter
    # overwrites `currency` with the reporting currency, so using it
    # directly would reject legitimate Art. 114(4) 0% treatment for any
    # non-base-currency exposure.
    ccy_expr = denomination_currency_expr(schema.names())
    is_uk_domestic = (pl.col("cp_country_code") == "GB") & (ccy_expr == "GBP")
    is_eu_domestic = build_eu_domestic_currency_expr("cp_country_code", ccy_expr)
    is_domestic_currency = is_uk_domestic | is_eu_domestic

    # Cache uppercase-class once and map detailed classes onto CQS-lookup
    # classes. Sentinel -1 for null CQS so the left join matches.
    upper = pl.col("exposure_class").str.to_uppercase()

    # CRR Art. 117(1) / PRA PS1/26 Art. 117(1)(a): non-named MDBs are treated
    # as institutions, so their primary CQS source is ``cp_institution_cqs``
    # (the MDB's own ECAI rating expressed as a CQS). When the exposure has
    # no top-level ``cqs`` (no rating attached at the rating-mapping stage)
    # but the counterparty carries an ``institution_cqs``, lift it into
    # ``cqs`` here so the downstream CQS-keyed branches and joins see it.
    # Named MDBs (mdb_named) bypass CQS entirely later — coalescing here is
    # harmless for them.
    is_mdb_class = upper == "MDB"
    # CRR Art. 107(2)(a): a non-qualifying CCP counterparty (entity_type "ccp"
    # demoted past the Art. 306(1) 2%/4% pin by cp_is_qccp=False) is treated as
    # an ordinary institution. Its own ECAI rating is carried on the synthetic
    # CCR row as ``cp_institution_cqs`` (the CCR adapter surfaces no top-level
    # ``cqs``), so lift it into ``cqs`` here — mirroring the MDB treatment —
    # so the Art. 120(1) Table 3 institution ladder resolves (e.g. CQS 2 -> 50%)
    # instead of the unrated 100% fallback. Scoped to ``ccp`` entity_type with a
    # null ``cqs`` so rated institutions and lending rows are untouched.
    is_non_qccp_institution = (pl.col("cp_entity_type").fill_null("") == "ccp") & ~pl.col(
        "cp_is_qccp"
    ).fill_null(True)
    exposures = exposures.with_columns(
        pl.when((is_mdb_class | is_non_qccp_institution) & pl.col("cqs").is_null())
        .then(pl.col("cp_institution_cqs"))
        .otherwise(pl.col("cqs"))
        .alias("cqs")
    )

    # PRA PS1/26 Art. 139(2B): for the purposes of Art. 122B(1) (the SA
    # specialised-lending routing), inferred / issuer-level (non-issue-specific)
    # ECAI assessments are disapplied. An SL exposure whose only resolved
    # external rating is not issue-specific must be treated as unrated, so we
    # null its CQS here. This re-routes it through the unrated SL override
    # (``b31_sa_sl_rw_expr``) instead of the rated-corporate CQS table. Scoped
    # to Basel 3.1 SL exposures only — ordinary rated corporates (Art. 122(2))
    # are untouched.
    if resolved_pack.feature("sa_sl_inferred_rating_disapplied"):
        is_sl_exposure = pl.col("sl_type").fill_null("").str.len_chars() > 0
        rating_not_issue_specific = (
            pl.col("external_rating_is_issue_specific").fill_null(True) == False  # noqa: E712
        )
        exposures = exposures.with_columns(
            pl.when(is_sl_exposure & rating_not_issue_specific)
            .then(pl.lit(None, dtype=pl.Int8))
            .otherwise(pl.col("cqs"))
            .alias("cqs")
        )

    exposures = exposures.with_columns(
        [
            pl.when(upper.str.contains("CENTRAL_GOVT", literal=True))
            .then(pl.lit("CENTRAL_GOVT_CENTRAL_BANK"))
            .when(upper == "RGLA")
            .then(pl.lit("RGLA"))
            .when(upper == "PSE")
            .then(pl.lit("PSE"))
            .when(upper == "MDB")
            .then(pl.lit("MDB"))
            .when(upper.str.contains("INSTITUTION", literal=True))
            .then(pl.lit("INSTITUTION"))
            .when(upper.str.contains("CORPORATE", literal=True))
            .then(pl.lit("CORPORATE"))
            # Rated SL uses corporate CQS table (Art. 122A(3))
            .when(upper.str.contains("SPECIALISED", literal=True))
            .then(pl.lit("CORPORATE"))
            .when(upper.str.contains("COVERED_BOND", literal=True))
            .then(pl.lit("COVERED_BOND"))
            .otherwise(upper)
            .alias("_lookup_class"),
            pl.col("cqs").fill_null(-1).cast(pl.Int8).alias("_lookup_cqs"),
            upper.alias("_upper_class"),
        ]
    )

    rw_table = rw_table.with_columns(
        pl.col("cqs").fill_null(-1).cast(pl.Int8).alias("cqs"),
    )
    exposures = exposures.join(
        rw_table.select(["exposure_class", "cqs", "risk_weight"]),
        left_on=["_lookup_class", "_lookup_cqs"],
        right_on=["exposure_class", "cqs"],
        how="left",
        suffix="_rw",
    )

    return exposures, pl.col("_upper_class"), is_domestic_currency

PS1/26, paragraph 147A — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

allocate — src/rwa_calc/engine/securitisation/allocator.py:118
    @cites("CRR Art. 109")
    @cites(CRR_ART_244)
    @cites("PS1/26, paragraph 147A")
    def allocate(
        self,
        data: RawDataBundle,
        config: CalculationConfig,  # noqa: ARG002 -- config reserved for future use
    ) -> tuple[RawDataBundle, pl.LazyFrame | None, list[CalculationError]]:
        """Resolve allocations into a per-exposure lookup.

        Args:
            data: Raw data bundle from loader.
            config: Calculation configuration (currently unused; reserved
                so the SRT validation gate can later read framework flags).

        Returns:
            Tuple of (original raw bundle, resolved lookup or None, list
            of validation errors). The lookup is None when no allocations
            were supplied; an empty input frame returns an empty lookup.
        """
        if data.securitisation_allocations is None:
            return data, None, []

        # Materialise once -- the allocator runs row-level validation that
        # is far easier to reason about on a concrete frame than on a
        # lazy plan, and the input table is by definition small (one row
        # per exposure-pool pair).
        raw = data.securitisation_allocations.collect()

        if raw.height == 0:
            return data, empty_resolved_lookup(), []

        errors: list[CalculationError] = []

        # ------------------------------------------------------------------
        # Step 1: SEC002 -- drop rows with invalid allocation_pct.
        # ------------------------------------------------------------------
        invalid_pct = raw.filter(
            (pl.col("allocation_pct").is_null())
            | (pl.col("allocation_pct") <= 0.0)
            | (pl.col("allocation_pct") > 1.0)
        )
        if invalid_pct.height > 0:
            errors.append(
                securitisation_warning(
                    code=ERROR_SEC_INVALID_PCT,
                    message=(
                        f"{invalid_pct.height} securitisation allocation row(s) had "
                        "allocation_pct outside (0, 1] or null; rows dropped."
                    ),
                    severity=ErrorSeverity.ERROR,
                    regulatory_reference=CRR_ART_244,
                )
            )
        raw = raw.filter(
            (pl.col("allocation_pct").is_not_null())
            & (pl.col("allocation_pct") > 0.0)
            & (pl.col("allocation_pct") <= 1.0)
        )

        if raw.height == 0:
            return data, empty_resolved_lookup(), errors

        # ------------------------------------------------------------------
        # Step 2: SEC003 -- orphan exposure_reference (unknown to any of
        # loans / contingents / facilities). Each row is checked against
        # the source table matching its exposure_type to keep the lookup
        # surface narrow.
        # ------------------------------------------------------------------
        known_refs = _collect_known_references(data)
        raw = raw.with_columns(
            pl.struct(["exposure_reference", "exposure_type"])
            .map_elements(
                lambda row: (row["exposure_reference"], row["exposure_type"]) in known_refs,
                return_dtype=pl.Boolean,
            )
            .alias("_is_known"),
        )
        unknown = raw.filter(~pl.col("_is_known"))
        if unknown.height > 0:
            errors.append(
                securitisation_warning(
                    code=ERROR_SEC_UNKNOWN_REFERENCE,
                    message=(
                        f"{unknown.height} securitisation allocation row(s) referenced "
                        "an exposure that does not exist in loans / contingents / "
                        "facilities; rows dropped."
                    ),
                    severity=ErrorSeverity.WARNING,
                    regulatory_reference=CRR_ART_244,
                )
            )
        raw = raw.filter(pl.col("_is_known")).drop("_is_known")

        if raw.height == 0:
            return data, empty_resolved_lookup(), errors

        # ------------------------------------------------------------------
        # Step 3: SEC004 -- duplicate (exposure_reference, pool_reference).
        # Keep first, drop subsequent.
        # ------------------------------------------------------------------
        before_dedup = raw.height
        raw = raw.unique(
            subset=["exposure_reference", "exposure_type", "pool_reference"],
            keep="first",
        )
        dropped = before_dedup - raw.height
        if dropped > 0:
            errors.append(
                securitisation_warning(
                    code=ERROR_SEC_DUPLICATE,
                    message=(
                        f"{dropped} duplicate (exposure_reference, pool_reference) "
                        "securitisation allocation row(s) dropped; first row kept."
                    ),
                    severity=ErrorSeverity.WARNING,
                    regulatory_reference=CRR_ART_244,
                )
            )

        # ------------------------------------------------------------------
        # Step 4: per-exposure aggregation. Group into struct list and
        # compute total_allocated_pct.
        # ------------------------------------------------------------------
        aggregated = (
            raw.lazy()
            .group_by(["exposure_reference", "exposure_type"])
            .agg(
                [
                    pl.struct(
                        [
                            pl.col("pool_reference"),
                            pl.col("allocation_pct"),
                        ]
                    ).alias("securitisation_pool_allocations"),
                    pl.col("allocation_pct").sum().alias("total_allocated_pct"),
                ]
            )
        ).collect()

        # ------------------------------------------------------------------
        # Step 5: SEC001 -- per-exposure sum > 1. Drop the allocations
        # entirely for those rows; the exposure is treated as fully
        # on-balance-sheet (residual_pct = 1.0) with audit_status =
        # "over_allocated" so the audit row still surfaces the issue.
        # ------------------------------------------------------------------
        # Use a small tolerance to absorb floating-point summation noise --
        # ``0.4 + 0.3 + 0.3`` is not exactly 1.0 in IEEE-754.
        _SUM_TOLERANCE = 1e-9
        over = aggregated.filter(pl.col("total_allocated_pct") > 1.0 + _SUM_TOLERANCE)
        if over.height > 0:
            errors.append(
                securitisation_warning(
                    code=ERROR_SEC_OVER_ALLOCATED,
                    message=(
                        f"{over.height} exposure(s) had securitisation allocations "
                        "summing to > 1.0; all pool slices dropped, exposure(s) "
                        "kept fully on-balance-sheet."
                    ),
                    severity=ErrorSeverity.ERROR,
                    regulatory_reference=CRR_ART_244,
                )
            )

        # ------------------------------------------------------------------
        # Step 6: SEC005 -- per-exposure sum == 1 (residual = 0). Inform-
        # ational only -- the exposure flows through the pipeline with
        # zero on-balance-sheet contribution.
        # ------------------------------------------------------------------
        fully = aggregated.filter(
            (pl.col("total_allocated_pct") >= 1.0 - _SUM_TOLERANCE)
            & (pl.col("total_allocated_pct") <= 1.0 + _SUM_TOLERANCE)
        )
        if fully.height > 0:
            errors.append(
                securitisation_warning(
                    code=ERROR_SEC_FULLY_SECURITISED,
                    message=(
                        f"{fully.height} exposure(s) fully securitised "
                        "(residual = 0); zero on-balance-sheet contribution."
                    ),
                    severity=ErrorSeverity.WARNING,
                    regulatory_reference=CRR_ART_244,
                )
            )

        # ------------------------------------------------------------------
        # Step 7: build the resolved lookup. Over-allocated rows keep
        # residual_pct = 1.0 and an empty pool_allocations list so the
        # aggregator does not double-count them.
        # ------------------------------------------------------------------
        is_over = pl.col("total_allocated_pct") > 1.0 + _SUM_TOLERANCE
        is_fully = (pl.col("total_allocated_pct") >= 1.0 - _SUM_TOLERANCE) & (
            pl.col("total_allocated_pct") <= 1.0 + _SUM_TOLERANCE
        )

        empty_struct_list = pl.lit([]).cast(
            pl.List(
                pl.Struct(
                    {
                        "pool_reference": pl.String,
                        "allocation_pct": pl.Float64,
                    }
                )
            )
        )

        resolved = aggregated.with_columns(
            [
                pl.when(is_over)
                .then(pl.lit(1.0))
                .otherwise((pl.lit(1.0) - pl.col("total_allocated_pct")).clip(lower_bound=0.0))
                .alias("securitisation_residual_pct"),
                pl.when(is_over)
                .then(empty_struct_list)
                .otherwise(pl.col("securitisation_pool_allocations"))
                .alias("securitisation_pool_allocations"),
                pl.when(is_over)
                .then(pl.lit("over_allocated"))
                .when(is_fully)
                .then(pl.lit("fully_securitised"))
                .otherwise(pl.lit("ok"))
                .alias("audit_status"),
            ]
        ).select(list(RESOLVED_SECURITISATION_SCHEMA.keys()))

        logger.info(
            "securitisation_allocator resolved %d exposure(s); %d error(s)",
            resolved.height,
            len(errors),
        )

        return data, resolved.lazy(), errors

PS1/26, paragraph 147A.1 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

derive_exposure_subclass — src/rwa_calc/engine/stages/classify/subtypes.py:279
@cites("PS1/26, paragraph 147A.1")
def derive_exposure_subclass(
    exposures: pl.LazyFrame,
    config: CalculationConfig,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """Derive the Basel 3.1 corporate ``exposure_subclass`` (PRA PS1/26 Art. 147A(1)).

    Basel 3.1 only — under CRR the column is null. For rows whose
    ``exposure_class`` is corporate / corporate_sme, the three-way split is:

      - ``corporate_financial_large`` — FSE (``cp_is_financial_sector_entity``)
        OR large corporate (``cp_annual_revenue`` > the Art. 147A(1)(d) GBP 440m
        threshold). Art. 147A(1)(e).
      - ``corporate_sme`` — ``is_sme`` (turnover <= GBP 44m). Art. 147A(1)(f).
      - ``corporate_other`` — otherwise. Art. 147A(1)(f).

    Reuses the FSE predicate and the large-corporate revenue threshold
    (``regulatory_threshold(pack, "large_corporate_revenue_threshold", …)``) shared
    with ``_apply_b31_approach_restrictions``; non-corporate rows stay null.
    """
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
    null_subclass = pl.lit(None, dtype=pl.String).alias("exposure_subclass")
    if not resolved_pack.feature("b31_exposure_subclass_reporting_applies"):
        return exposures.with_columns(null_subclass)

    is_corporate = pl.col("exposure_class").is_in(
        [ExposureClass.CORPORATE.value, ExposureClass.CORPORATE_SME.value]
    )

    is_fse = (pl.col("cp_is_financial_sector_entity") == True).fill_null(False)  # noqa: E712

    is_large_by_revenue = (
        pl.col("cp_annual_revenue")
        > float(
            regulatory_threshold(
                resolved_pack, "large_corporate_revenue_threshold", config.eur_gbp_rate
            )
        )
    ).fill_null(False)

    is_sme = pl.col("is_sme").fill_null(False)

    subclass = (
        pl.when(~is_corporate)
        .then(pl.lit(None, dtype=pl.String))
        .when(is_fse | is_large_by_revenue)
        .then(pl.lit(ExposureSubclass.CORPORATE_FINANCIAL_LARGE.value))
        .when(is_sme)
        .then(pl.lit(ExposureSubclass.CORPORATE_SME.value))
        .otherwise(pl.lit(ExposureSubclass.CORPORATE_OTHER.value))
        .alias("exposure_subclass")
    )
    return exposures.with_columns(subclass)

PS1/26, paragraph 162 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

_derive_ccr_sft_maturity_years — src/rwa_calc/engine/sft/fccm.py:233
@cites("CRR Art. 162")
@cites("PS1/26, paragraph 162")
def _derive_ccr_sft_maturity_years(
    *,
    remaining_years: float | None,
    under_mna: bool,
    qualifies_one_day_floor: bool,
    qualifies_mna_intermediate_floor: bool,
    pack: ResolvedRulepack,
) -> float | None:
    """Return the Art. 162 effective maturity M for one SFT netting set, or None.

    The carrier is the FULL M = ``clip(remaining_years, floor, 5.0)`` — the floor
    is a MINIMUM on the remaining maturity (Art. 162(2)(d)/(3)), never a fixed
    replacement value. For a long-dated MNA exposure the floor does not bite and
    M = ``remaining_years``. Returns ``None`` (the date-derived 1-year catch-all,
    Art. 162(2)(f) / PS1/26 162(2A)(f)) when the row is not under a master netting
    agreement or carries no maturity.

    Floor precedence (all sub-1y floors require the MNA precondition):

    - not under an MNA, or ``remaining_years is None`` -> ``None`` (1y catch-all).
    - ``qualifies_one_day_floor`` (the three conjunctive Art. 162(3) conditions —
      daily re-margin AND revaluation AND prompt-liquidation docs) -> the one-day
      (~1/365 y) floor.
    - else the 5BD repo/SFT floor (Art. 162(2)(d) / PS1/26 162(2A)(d)). Under B31
      the intermediate floor additionally requires the 162(2A)(c)/(d) daily
      documentation condition (gated by the
      ``mna_intermediate_floor_requires_daily_condition`` feature); without it the
      row falls to the 1-year catch-all (``None``). Under CRR the floor applies on
      MNA alone (the feature is off).

    Floors / feature are read from the RUN ``pack`` (not the module ``_PACK``) so
    the derivation is regime-correct.

    Args:
        remaining_years: Exact /365 fractional years to maturity, or None.
        under_mna: Art. 162(2) master-netting-agreement precondition.
        qualifies_one_day_floor: All three Art. 162(3) conditions hold.
        qualifies_mna_intermediate_floor: The B31 162(2A)(c)/(d) daily condition.
        pack: The resolved run rulepack supplying the cited maturity floors / gate.

    Returns:
        M as a float, or ``None`` for the date-derived 1-year catch-all.
    """
    if not under_mna or remaining_years is None:
        return None
    cap = 5.0
    if qualifies_one_day_floor:
        floor = float(pack.scalar_param("one_day_maturity_floor_years").value)
    else:
        requires_daily = pack.feature("mna_intermediate_floor_requires_daily_condition")
        intermediate_available = (not requires_daily) or qualifies_mna_intermediate_floor
        if not intermediate_available:
            return None
        floor = float(pack.scalar_param("irb_maturity_floor_repo_sft_years").value)
    return min(max(remaining_years, floor), cap)

PS1/26, paragraph 163 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

_pd_floor_expression — src/rwa_calc/engine/irb/formulas.py:110
@cites("CRR Art. 163")
@cites("PS1/26, paragraph 163")
def _pd_floor_expression(
    config: CalculationConfig,
    *,
    has_transactor_col: bool = True,
    exposure_class_col: str = "exposure_class",
    transactor_col: str = "is_qrre_transactor",
    pack: ResolvedRulepack | None = None,
) -> pl.Expr:
    """
    Build Polars expression for per-exposure-class PD floor.

    Under CRR (Art. 163): Uniform 0.03% floor for all exposure classes.
    Under Basel 3.1 (CRE30.55): Differentiated floors:
        - Corporate/SME: 0.05%
        - Retail mortgage: 0.10% (Art. 163(1)(b))
        - QRRE transactors: 0.05%, revolvers: 0.10% (Art. 163(1)(c))
        - Retail other: 0.05%

    Args:
        config: Calculation configuration
        has_transactor_col: Whether the LazyFrame has the transactor column.
            When True (pipeline path), uses per-row transactor/revolver distinction.
            When False (isolated expressions), defaults to conservative revolver floor.
        exposure_class_col: Name of the column to read the exposure class from.
            Defaults to ``exposure_class`` (the borrower's class). For guarantor
            PD substitution (CRR Art. 161(3) / B31 CRE22.70-85, Art. 160(4)),
            pass ``guarantor_exposure_class`` so the floor reads the guarantor's
            own class — the guaranteed portion is treated as a direct exposure
            to the guarantor, so the guarantor's class floor governs.
        transactor_col: Name of the QRRE transactor flag column. For guarantor
            PD floors this is normally not relevant (guarantors are typically
            not QRRE), but the parameter is exposed for symmetry with
            ``exposure_class_col``.

    Returns a Polars expression evaluating to the per-row PD floor value.
    """
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
    floors = formula_float_map(resolved_pack.formula("pd_floors"))

    # Optimisation: if all floors are the same (CRR case), return a scalar
    all_values = set(floors.values())
    if len(all_values) == 1:
        return pl.lit(all_values.pop())

    # Basel 3.1: differentiated floors by exposure class
    exp_class = pl.col(exposure_class_col).cast(pl.String).fill_null("CORPORATE").str.to_uppercase()

    # QRRE transactor/revolver distinction (CRE30.55):
    # Transactors (repay in full each period) get 0.03% floor;
    # revolvers (carry balance) get 0.10% floor.
    if has_transactor_col:
        qrre_floor = (
            pl.when(pl.col(transactor_col).fill_null(False))
            .then(pl.lit(floors["retail_qrre_transactor"]))
            .otherwise(pl.lit(floors["retail_qrre_revolver"]))
        )
    else:
        # Conservative default: revolver floor (0.10% under Basel 3.1)
        qrre_floor = pl.lit(floors["retail_qrre_revolver"])

    sovereign_value = ExposureClass.CENTRAL_GOVT_CENTRAL_BANK.value.upper()
    institution_value = ExposureClass.INSTITUTION.value.upper()

    return (
        pl.when(exp_class.str.contains("QRRE"))
        .then(qrre_floor)
        .when(exp_class.str.contains("MORTGAGE") | exp_class.str.contains("RESIDENTIAL"))
        .then(pl.lit(floors["retail_mortgage"]))
        .when(exp_class.str.contains("RETAIL"))
        .then(pl.lit(floors["retail_other"]))
        .when(exp_class == "CORPORATE_SME")
        .then(pl.lit(floors["corporate_sme"]))
        .when(exp_class == sovereign_value)
        .then(pl.lit(floors["sovereign"]))
        .when(exp_class == institution_value)
        .then(pl.lit(floors["institution"]))
        .otherwise(pl.lit(floors["corporate"]))
    )
apply_pd_floor — src/rwa_calc/engine/irb/transforms.py:277
@cites("CRR Art. 163")
@cites("PS1/26, paragraph 163")
def apply_pd_floor(
    lf: pl.LazyFrame, config: CalculationConfig, *, pack: ResolvedRulepack | None = None
) -> pl.LazyFrame:
    """
    Apply PD floor based on configuration.

    CRR (Art. 163): 0.03% for all classes
    Basel 3.1 (CRE30.55): Differentiated by class
        - Corporate/SME: 0.05%
        - Retail mortgage: 0.05%
        - QRRE revolvers: 0.10%, transactors: 0.03%
        - Retail other: 0.05%

    Args:
        lf: IRB exposures frame
        config: Calculation configuration
        pack: Resolved rulepack (falls back to ``config`` when omitted)

    Returns:
        LazyFrame with pd_floored column
    """
    pd_floor_expr = _pd_floor_expression(config, pack=pack)
    return lf.with_columns(pl.max_horizontal(pl.col("pd"), pd_floor_expr).alias("pd_floored"))

PS1/26, paragraph 164 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

_lgd_floor_expression — src/rwa_calc/engine/irb/formulas.py:192
@cites("CRR Art. 164")
@cites("PS1/26, paragraph 164")
def _lgd_floor_expression(
    config: CalculationConfig,
    *,
    has_seniority: bool = False,
    has_exposure_class: bool = False,
    pack: ResolvedRulepack | None = None,
) -> pl.Expr:
    """
    Build Polars expression for LGD floor (no collateral_type column).

    Under CRR: No LGD floors (returns 0.0).
    Under Basel 3.1: Differentiated floors for A-IRB by exposure class:
        Corporate (Art. 161(5)): 25% unsecured (senior & subordinated alike)
        Retail (Art. 164(4)):
            - retail_mortgage: 5% (assumed RRE-secured)
            - retail_qrre: 50% (Art. 164(4)(b)(i))
            - retail_other: 30% (Art. 164(4)(b)(ii))

    Without exposure_class, falls back to seniority-based logic (conservative).
    Without either, defaults to 25% unsecured floor.

    Returns a Polars expression evaluating to the per-row LGD floor value.
    """
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
    if not resolved_pack.feature("airb_lgd_floor"):
        return pl.lit(0.0)

    floors = formula_float_map(resolved_pack.formula("lgd_floors"))

    if has_exposure_class:
        # Route by exposure class — retail gets Art. 164(4) floors
        exp_class = pl.col("exposure_class").cast(pl.String).str.to_lowercase()
        return (
            pl.when(exp_class.is_in(["retail_mortgage"]))
            .then(pl.lit(floors["retail_rre"]))  # 5% Art. 164(4)(a)
            .when(exp_class.is_in(["retail_qrre"]))
            .then(pl.lit(floors["retail_qrre_unsecured"]))  # 50% Art. 164(4)(b)(i)
            .when(exp_class.is_in(["retail_other"]))
            .then(pl.lit(floors["retail_other_unsecured"]))  # 30% Art. 164(4)(b)(ii)
            .otherwise(pl.lit(floors["unsecured"]))  # 25% Art. 161(5)
        )

    if has_seniority:
        # Fallback without exposure_class: corporate A-IRB applies a single 25%
        # unsecured floor regardless of seniority (Art. 161(5)). The 50%
        # subordinated_unsecured value is the F-IRB supervisory LGD per
        # Art. 161(1)(b), not an A-IRB floor — do not branch on seniority here.
        return pl.lit(floors["unsecured"])

    # Default to unsecured floor (25%) — most conservative for senior
    return pl.lit(floors["unsecured"])
_lgd_floor_expression_with_collateral — src/rwa_calc/engine/irb/formulas.py:246
@cites("PS1/26, paragraph 164")
def _lgd_floor_expression_with_collateral(
    config: CalculationConfig,
    *,
    has_seniority: bool = False,
    has_exposure_class: bool = False,
    pack: ResolvedRulepack | None = None,
) -> pl.Expr:
    """
    Build Polars expression for per-collateral-type LGD floor when collateral_type
    column is available.

    When has_exposure_class=True, applies retail-specific floors (Art. 164(4)):
        - retail_mortgage + RRE collateral: 5% (Art. 164(4)(a))
        - retail_qrre unsecured: 50% (Art. 164(4)(b)(i))
        - retail_other unsecured: 30% (Art. 164(4)(b)(ii))
        - retail + other collateral: same LGDS as corporate (0%/10%/10%/15%)
    Corporate floors use Art. 161(5): 25% unsecured, collateral-type LGDS.
    """
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
    if not resolved_pack.feature("airb_lgd_floor"):
        return pl.lit(0.0)

    floors = formula_float_map(resolved_pack.formula("lgd_floors"))
    coll = pl.col("collateral_type").fill_null("unsecured").str.to_lowercase()

    # Determine unsecured floor based on exposure class (retail vs corporate)
    if has_exposure_class:
        exp_class = pl.col("exposure_class").cast(pl.String).str.to_lowercase()
        unsecured_floor = (
            pl.when(exp_class.is_in(["retail_mortgage"]))
            .then(pl.lit(floors["retail_rre"]))  # 5% Art. 164(4)(a)
            .when(exp_class.is_in(["retail_qrre"]))
            .then(pl.lit(floors["retail_qrre_unsecured"]))  # 50% Art. 164(4)(b)(i)
            .when(exp_class.is_in(["retail_other"]))
            .then(pl.lit(floors["retail_other_unsecured"]))  # 30% Art. 164(4)(b)(ii)
            .otherwise(pl.lit(floors["unsecured"]))  # 25% Art. 161(5)
        )
        # RRE collateral floor: 5% for retail_mortgage, 10% for corporate
        rre_floor = (
            pl.when(exp_class.is_in(["retail_mortgage"]))
            .then(pl.lit(floors["retail_rre"]))  # 5% Art. 164(4)(a)
            .otherwise(pl.lit(floors["residential_real_estate"]))  # 10% Art. 161(5)
        )
    elif has_seniority:
        # Fallback without exposure_class: corporate A-IRB applies a single 25%
        # unsecured floor regardless of seniority (Art. 161(5)). The 50%
        # subordinated_unsecured value is the F-IRB supervisory LGD per
        # Art. 161(1)(b), not an A-IRB floor — do not branch on seniority here.
        unsecured_floor = pl.lit(floors["unsecured"])
        rre_floor = pl.lit(floors["residential_real_estate"])
    else:
        unsecured_floor = pl.lit(floors["unsecured"])
        rre_floor = pl.lit(floors["residential_real_estate"])

    return (
        pl.when(coll.is_in(["financial_collateral", "cash", "deposit", "gold", "financial"]))
        .then(pl.lit(floors["financial_collateral"]))
        .when(coll.is_in(["receivables", "trade_receivables"]))
        .then(pl.lit(floors["receivables"]))
        .when(coll.is_in(["residential_re", "rre", "residential", "residential_property"]))
        .then(rre_floor)
        .when(coll.is_in(["commercial_re", "cre", "commercial", "commercial_property"]))
        .then(pl.lit(floors["commercial_real_estate"]))
        .when(coll.is_in(["real_estate", "property", "immovable"]))
        .then(rre_floor)  # Routes to 5% for retail_mortgage, 10% for corporate (P1.8)
        .when(coll.is_in(["other_physical", "equipment", "inventory"]))
        .then(pl.lit(floors["other_physical"]))
        .otherwise(unsecured_floor)
    )
_lgd_floor_blended_expression — src/rwa_calc/engine/irb/formulas.py:318
@cites("PS1/26, paragraph 164")
def _lgd_floor_blended_expression(
    config: CalculationConfig,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.Expr:
    """
    Build Polars expression for the Art. 164(4)(c) blended LGD floor.

    For retail "other secured" exposures with mixed collateral, the LGD floor
    is a weighted average of per-type LGDS floors and the unsecured LGDU,
    weighted by the proportion of EAD covered by each collateral type:

        LGD_floor = (E_unsecured / EAD) × LGDU
                  + Σ_i (E_i / EAD) × LGDS_i

    Where E_i comes from the Art. 231 sequential waterfall (crm_alloc_* columns)
    and E_unsecured = EAD - total_collateral_for_lgd.

    Applies to:
        - retail_other (LGDU = 30%)
        - retail_qrre (LGDU = 50%)
    Does NOT apply to:
        - retail_mortgage (flat 5% floor per Art. 164(4)(a))
        - CRR (no LGD floors)

    Requires crm_alloc_* columns from CRM waterfall and ead_gross.
    Falls back to _lgd_floor_expression_with_collateral() for non-retail or
    when allocation columns are absent.

    References:
        PRA PS1/26 Art. 164(4)(c)
    """
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack
    if not resolved_pack.feature("airb_lgd_floor"):
        return pl.lit(0.0)

    floors = formula_float_map(resolved_pack.formula("lgd_floors"))

    ead = pl.col("ead_gross")
    total_coll = pl.col("total_collateral_for_lgd").fill_null(0.0)
    unsecured_portion = (ead - total_coll).clip(lower_bound=0.0)

    alloc_fin = pl.col("crm_alloc_financial").fill_null(0.0)
    alloc_cb = pl.col("crm_alloc_covered_bond").fill_null(0.0)
    alloc_rec = pl.col("crm_alloc_receivables").fill_null(0.0)
    alloc_re = pl.col("crm_alloc_real_estate").fill_null(0.0)
    alloc_op = pl.col("crm_alloc_other_physical").fill_null(0.0)
    alloc_li = pl.col("crm_alloc_life_insurance").fill_null(0.0)

    # Per-type LGDS floors for retail (Art. 164(4)(c))
    lgds_fin = floors["financial_collateral"]  # 0%
    lgds_cb = floors["financial_collateral"]  # 0% (treated as financial)
    lgds_rec = floors["receivables"]  # 10%
    lgds_re = floors["commercial_real_estate"]  # 10% (non-RRE immovable property)
    lgds_op = floors["other_physical"]  # 15%
    lgds_li = floors["financial_collateral"]  # 0% (treated as financial)

    numerator = (
        alloc_fin * lgds_fin
        + alloc_cb * lgds_cb
        + alloc_rec * lgds_rec
        + alloc_re * lgds_re
        + alloc_op * lgds_op
        + alloc_li * lgds_li
    )

    # LGDU depends on exposure class:
    # - retail_other: 30% (Art. 164(4)(c))
    # - retail_qrre: 50% (Art. 164(4)(b)(i))
    exp_class = pl.col("exposure_class").cast(pl.String).str.to_lowercase()
    lgdu_expr = (
        pl.when(exp_class.is_in(["retail_qrre"]))
        .then(pl.lit(floors["retail_qrre_unsecured"]))  # 50%
        .otherwise(pl.lit(floors["retail_lgdu"]))  # 30%
    )

    numerator_with_unsecured = numerator + unsecured_portion * lgdu_expr

    blended = pl.when(ead > 0).then(numerator_with_unsecured / ead).otherwise(pl.lit(0.0))

    # Apply blended floor only to retail_other and retail_qrre with collateral.
    # retail_mortgage uses flat 5% (Art. 164(4)(a)).
    # Corporate uses single-type floor from _lgd_floor_expression_with_collateral().
    is_blended_eligible = exp_class.is_in(["retail_other", "retail_qrre"])
    has_collateral = total_coll > 0

    return pl.when(is_blended_eligible & has_collateral).then(blended).otherwise(pl.lit(None))
apply_lgd_floor — src/rwa_calc/engine/irb/transforms.py:304
@cites("CRR Art. 164")
@cites("PS1/26, paragraph 164")
def apply_lgd_floor(
    lf: pl.LazyFrame, config: CalculationConfig, *, pack: ResolvedRulepack | None = None
) -> pl.LazyFrame:
    """
    Apply LGD floor for Basel 3.1 A-IRB exposures.

    Uses lgd_input (which contains collateral-adjusted LGD for F-IRB)
    as the base for flooring.

    CRR: No LGD floor (A-IRB models LGD freely)
    Basel 3.1: Differentiated floors by collateral type and exposure class:
        - Corporate unsecured (senior & subordinated): 25% (Art. 161(5))
        - Retail QRRE unsecured: 50% (Art. 164(4)(b)(i))
        - Financial: 0%, Receivables: 10%
        - RRE: 10%, CRE: 10%, Other physical: 15%

    LGD floors only apply to A-IRB own-estimate LGDs. F-IRB supervisory
    LGDs are regulatory values and don't need flooring.

    Args:
        lf: IRB exposures frame
        config: Calculation configuration
        pack: Resolved rulepack (falls back to ``config`` when omitted)

    Returns:
        LazyFrame with lgd_floored column
    """
    schema = lf.collect_schema()
    schema_names = schema.names()
    lgd_col = "lgd_input" if "lgd_input" in schema_names else "lgd"
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack

    if resolved_pack.feature("airb_lgd_floor"):
        if "collateral_type" in schema_names:
            lgd_floor_expr = _lgd_floor_expression_with_collateral(
                config,
                has_seniority=True,
                has_exposure_class=True,
                pack=resolved_pack,
            )
        else:
            lgd_floor_expr = _lgd_floor_expression(
                config,
                has_seniority=True,
                has_exposure_class=True,
                pack=resolved_pack,
            )

        # Art. 164(4)(c) blended floor for retail with mixed collateral
        # Use blended floor where applicable (retail_other/qrre with collateral),
        # fall back to single-type floor otherwise
        blended_expr = _lgd_floor_blended_expression(config, pack=resolved_pack)
        lgd_floor_expr = (
            pl.when(blended_expr.is_not_null()).then(blended_expr).otherwise(lgd_floor_expr)
        )

        # LGD floors only apply to A-IRB (CRE30.41); F-IRB uses supervisory LGD
        is_airb = pl.col("is_airb").fill_null(False) if "is_airb" in schema_names else pl.lit(False)
        floored_lgd = pl.max_horizontal(pl.col(lgd_col), lgd_floor_expr)
        return lf.with_columns(
            pl.when(is_airb).then(floored_lgd).otherwise(pl.col(lgd_col)).alias("lgd_floored")
        )
    return lf.with_columns(pl.col(lgd_col).alias("lgd_floored"))

PS1/26, paragraph 166.5 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

_apply_purchased_receivable_ccf — src/rwa_calc/engine/ccf.py:592
    @cites("PS1/26, paragraph 166.5")
    def _apply_purchased_receivable_ccf(
        self,
        exposures: pl.LazyFrame,
    ) -> pl.LazyFrame:
        """Apply the Art. 166E(5) revolving purchased-receivables CCF routing.

        PRA PS1/26 Art. 166E(5): the undrawn purchase commitment of a *revolving*
        purchased-receivables facility receives a fixed CCF — 40% by default
        (Art. 111(1) Table A1 Row 5 "Other Commitments" / OC), dropping to 10%
        where the commitment also meets the Table A1 Row 7 UCC criteria
        (``risk_type == "LR"``). When ``is_purchased_receivable_commitment`` and
        ``is_revolving`` are both True, the otherwise-resolved SA / F-IRB CCF is
        overridden to this rate regardless of the row's generic risk_type bucket
        (e.g. a flagged MR row routes to 40%, not the generic 50%).

        Basel-3.1-only: callers gate this on the ``firb_uses_sa_ccf`` pack Feature
        (S9c); there is no equivalent CRR purchased-receivables undrawn-commitment
        CCF, so the flag is a no-op under CRR.
        """
        oc_ccf = _SA_CCF_B31_MAP["OC"]
        ucc_ccf = _SA_CCF_B31_MAP["LR"]

        is_pr_commitment = pl.col("is_purchased_receivable_commitment").fill_null(False) & pl.col(
            "is_revolving"
        ).fill_null(False)
        # Table A1 Row 7 UCC criterion: the commitment is unconditionally
        # cancellable (LR risk_type) -> 10%; otherwise the Row 5 OC 40% default.
        is_ucc = pl.col("risk_type").fill_null("").str.to_lowercase().is_in(["lr", "low_risk"])
        pr_ccf = pl.when(is_ucc).then(pl.lit(ucc_ccf)).otherwise(pl.lit(oc_ccf))

        return exposures.with_columns(
            pl.when(is_pr_commitment)
            .then(pr_ccf)
            .otherwise(pl.col("_sa_ccf_from_risk_type"))
            .alias("_sa_ccf_from_risk_type"),
            pl.when(is_pr_commitment)
            .then(pr_ccf)
            .otherwise(pl.col("_firb_ccf_from_risk_type"))
            .alias("_firb_ccf_from_risk_type"),
        )

PS1/26 Art. 230 — PRA Rulebook: CRR Firms: (CRR) Instrument 2026

apply_collateral — src/rwa_calc/engine/crm/collateral.py:300
@cites("PS1/26 Art. 230(2)")
@cites("PS1/26 Art. 230(1)")
@cites("CRR Art. 223")
@cites("CRR Art. 230")
def apply_collateral(
    exposures: pl.LazyFrame,
    collateral: pl.LazyFrame,
    config: CalculationConfig,
    haircut_calculator: HaircutCalculator,
    build_exposure_lookups_fn: Callable,
    join_collateral_to_lookups_fn: Callable,
    resolve_pledge_from_joined_fn: Callable,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """
    Apply collateral to reduce EAD (SA) or LGD (IRB).

    Pre-computes shared exposure lookups once, then joins ALL lookup columns
    (EAD, currency, maturity) in a single pass of 3 joins. Pledge resolution
    and currency/maturity derivation operate on pre-joined columns — no
    additional joins needed.

    Args:
        exposures: Exposures with ead_gross
        collateral: Collateral data
        config: Calculation configuration
        haircut_calculator: HaircutCalculator instance
        build_exposure_lookups_fn: Function to build exposure lookups
        join_collateral_to_lookups_fn: Function to join collateral to lookups
        resolve_pledge_from_joined_fn: Function to resolve pledge percentages

    Returns:
        Exposures with collateral effects applied
    """
    # Tag each exposure with its AIRB-pool membership so downstream pro-rata
    # bases can be split into AIRB and non-AIRB pools. CRR Art. 181 / Basel 3.1
    # Art. 169A: AIRB own LGD already reflects collateral, so collateral
    # incorporated in the model must not also be allocated to non-AIRB
    # exposures of the same counterparty.
    schema_names = set(exposures.collect_schema().names())

    # Graceful fallback for direct unit-test callers that hand-build the
    # exposures frame without going through _initialize_ead.  In production
    # both columns are always present.  For pure on-BS rows the defaults
    # produce identical behaviour to the explicit columns, so existing
    # tests stay green without modification.
    fallback_cols: list[pl.Expr] = []
    if "ead_for_crm" not in schema_names:
        fallback_cols.append(pl.col("ead_gross").alias("ead_for_crm"))
    if "effective_ccf" not in schema_names:
        fallback_cols.append(pl.lit(1.0).alias("effective_ccf"))
    if fallback_cols:
        exposures = exposures.with_columns(fallback_cols)
        schema_names |= {expr.meta.output_name() for expr in fallback_cols}

    # S9h: resolve the pack once; the collateral-LGD regime branches downstream
    # (haircut maturity bands, AIRB pool membership, FSE split, Art. 230(2) sub-rows)
    # read honest cited Features off it instead of a single config.is_basel_3_1 bool.
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack

    # CRR Art. 223(5) FCCM exposure volatility haircut (HE). Computed once on
    # the exposure frame so the SA branch in ``_apply_collateral_unified`` can
    # gross E by (1 + HE). Non-SFT / cash / standard-loan rows yield HE = 0.
    exposures = haircut_calculator.apply_exposure_haircut(
        exposures,
        resolved_pack.feature("collateral_haircut_maturity_bands_revised"),
        pack=resolved_pack,
    )

    exposures = exposures.with_columns(
        airb_lgd_preserved_expr(config, schema_names, pack=resolved_pack).alias("_is_airb_pool")
    )

    # Pre-compute shared exposure lookups once
    direct_lookup, facility_lookup, cp_lookup = build_exposure_lookups_fn(exposures)

    # Materialise the small lookup frames in parallel to prevent plan-tree
    # duplication. Each lookup is referenced in multiple downstream joins;
    # without this, Polars re-evaluates the group_by/select at each reference.
    # collect_all runs all 3 concurrently and enables CSE on shared upstream.
    direct_df, facility_df, cp_df = pl.collect_all([direct_lookup, facility_lookup, cp_lookup])
    direct_lookup = direct_df.lazy()
    facility_lookup = facility_df.lazy()
    cp_lookup = cp_df.lazy()

    # Derive pool-aware counterparty EAD totals from the lookups. Unflagged
    # collateral pro-rates over the non-AIRB pool only; flagged collateral
    # (is_airb_model_collateral=True) pro-rates over the AIRB pool only.
    # Facility-level subtree totals are derived per-ancestor inside
    # ``_apply_collateral_unified`` (``_cascade_facility_collateral``) so that
    # collateral pledged at any ancestor facility cascades over its whole
    # descendant subtree for nested facility hierarchies.
    cp_ead_totals = cp_lookup.select(
        pl.col("_ben_ref_cp").alias("counterparty_reference"),
        pl.col("_ead_cp").alias("_cp_ead_total"),
        pl.col("_ead_cp_airb").alias("_cp_ead_total_airb"),
        pl.col("_ead_cp_non_airb").alias("_cp_ead_total_non_airb"),
    )

    # Single pass: join all lookup columns (EAD, currency, maturity)
    collateral = join_collateral_to_lookups_fn(
        collateral, direct_lookup, facility_lookup, cp_lookup
    )

    # Resolve pledge_percentage → market_value (uses pre-joined _beneficiary_ead)
    collateral = resolve_pledge_from_joined_fn(collateral)

    # Apply haircuts to collateral (no longer needs exposures)
    adjusted_collateral = haircut_calculator.apply_haircuts(collateral, config, pack=pack)

    # Apply maturity mismatch using actual exposure maturity (Art. 238)
    adjusted_collateral = haircut_calculator.apply_maturity_mismatch(adjusted_collateral, config)

    # Opt-in audit cache: persist the per-collateral haircut frame for inspection.
    # No-op unless config.audit_cache_dir is set. Surfaces fx_haircut /
    # collateral_haircut / value_after_haircut / value_after_maturity_adj — the
    # diagnostic columns users need to confirm whether H_fx is firing on a row.
    sink_audit(adjusted_collateral, config, "collateral_haircuts")

    return _apply_collateral_unified(
        exposures,
        adjusted_collateral,
        config,
        cp_ead_totals,
        pack=resolved_pack,
    )
apply_collateral — src/rwa_calc/engine/crm/collateral.py:301
@cites("PS1/26 Art. 230(2)")
@cites("PS1/26 Art. 230(1)")
@cites("CRR Art. 223")
@cites("CRR Art. 230")
def apply_collateral(
    exposures: pl.LazyFrame,
    collateral: pl.LazyFrame,
    config: CalculationConfig,
    haircut_calculator: HaircutCalculator,
    build_exposure_lookups_fn: Callable,
    join_collateral_to_lookups_fn: Callable,
    resolve_pledge_from_joined_fn: Callable,
    *,
    pack: ResolvedRulepack | None = None,
) -> pl.LazyFrame:
    """
    Apply collateral to reduce EAD (SA) or LGD (IRB).

    Pre-computes shared exposure lookups once, then joins ALL lookup columns
    (EAD, currency, maturity) in a single pass of 3 joins. Pledge resolution
    and currency/maturity derivation operate on pre-joined columns — no
    additional joins needed.

    Args:
        exposures: Exposures with ead_gross
        collateral: Collateral data
        config: Calculation configuration
        haircut_calculator: HaircutCalculator instance
        build_exposure_lookups_fn: Function to build exposure lookups
        join_collateral_to_lookups_fn: Function to join collateral to lookups
        resolve_pledge_from_joined_fn: Function to resolve pledge percentages

    Returns:
        Exposures with collateral effects applied
    """
    # Tag each exposure with its AIRB-pool membership so downstream pro-rata
    # bases can be split into AIRB and non-AIRB pools. CRR Art. 181 / Basel 3.1
    # Art. 169A: AIRB own LGD already reflects collateral, so collateral
    # incorporated in the model must not also be allocated to non-AIRB
    # exposures of the same counterparty.
    schema_names = set(exposures.collect_schema().names())

    # Graceful fallback for direct unit-test callers that hand-build the
    # exposures frame without going through _initialize_ead.  In production
    # both columns are always present.  For pure on-BS rows the defaults
    # produce identical behaviour to the explicit columns, so existing
    # tests stay green without modification.
    fallback_cols: list[pl.Expr] = []
    if "ead_for_crm" not in schema_names:
        fallback_cols.append(pl.col("ead_gross").alias("ead_for_crm"))
    if "effective_ccf" not in schema_names:
        fallback_cols.append(pl.lit(1.0).alias("effective_ccf"))
    if fallback_cols:
        exposures = exposures.with_columns(fallback_cols)
        schema_names |= {expr.meta.output_name() for expr in fallback_cols}

    # S9h: resolve the pack once; the collateral-LGD regime branches downstream
    # (haircut maturity bands, AIRB pool membership, FSE split, Art. 230(2) sub-rows)
    # read honest cited Features off it instead of a single config.is_basel_3_1 bool.
    resolved_pack = pack if pack is not None else RulepackV0.from_config(config).pack

    # CRR Art. 223(5) FCCM exposure volatility haircut (HE). Computed once on
    # the exposure frame so the SA branch in ``_apply_collateral_unified`` can
    # gross E by (1 + HE). Non-SFT / cash / standard-loan rows yield HE = 0.
    exposures = haircut_calculator.apply_exposure_haircut(
        exposures,
        resolved_pack.feature("collateral_haircut_maturity_bands_revised"),
        pack=resolved_pack,
    )

    exposures = exposures.with_columns(
        airb_lgd_preserved_expr(config, schema_names, pack=resolved_pack).alias("_is_airb_pool")
    )

    # Pre-compute shared exposure lookups once
    direct_lookup, facility_lookup, cp_lookup = build_exposure_lookups_fn(exposures)

    # Materialise the small lookup frames in parallel to prevent plan-tree
    # duplication. Each lookup is referenced in multiple downstream joins;
    # without this, Polars re-evaluates the group_by/select at each reference.
    # collect_all runs all 3 concurrently and enables CSE on shared upstream.
    direct_df, facility_df, cp_df = pl.collect_all([direct_lookup, facility_lookup, cp_lookup])
    direct_lookup = direct_df.lazy()
    facility_lookup = facility_df.lazy()
    cp_lookup = cp_df.lazy()

    # Derive pool-aware counterparty EAD totals from the lookups. Unflagged
    # collateral pro-rates over the non-AIRB pool only; flagged collateral
    # (is_airb_model_collateral=True) pro-rates over the AIRB pool only.
    # Facility-level subtree totals are derived per-ancestor inside
    # ``_apply_collateral_unified`` (``_cascade_facility_collateral``) so that
    # collateral pledged at any ancestor facility cascades over its whole
    # descendant subtree for nested facility hierarchies.
    cp_ead_totals = cp_lookup.select(
        pl.col("_ben_ref_cp").alias("counterparty_reference"),
        pl.col("_ead_cp").alias("_cp_ead_total"),
        pl.col("_ead_cp_airb").alias("_cp_ead_total_airb"),
        pl.col("_ead_cp_non_airb").alias("_cp_ead_total_non_airb"),
    )

    # Single pass: join all lookup columns (EAD, currency, maturity)
    collateral = join_collateral_to_lookups_fn(
        collateral, direct_lookup, facility_lookup, cp_lookup
    )

    # Resolve pledge_percentage → market_value (uses pre-joined _beneficiary_ead)
    collateral = resolve_pledge_from_joined_fn(collateral)

    # Apply haircuts to collateral (no longer needs exposures)
    adjusted_collateral = haircut_calculator.apply_haircuts(collateral, config, pack=pack)

    # Apply maturity mismatch using actual exposure maturity (Art. 238)
    adjusted_collateral = haircut_calculator.apply_maturity_mismatch(adjusted_collateral, config)

    # Opt-in audit cache: persist the per-collateral haircut frame for inspection.
    # No-op unless config.audit_cache_dir is set. Surfaces fx_haircut /
    # collateral_haircut / value_after_haircut / value_after_maturity_adj — the
    # diagnostic columns users need to confirm whether H_fx is firing on a row.
    sink_audit(adjusted_collateral, config, "collateral_haircuts")

    return _apply_collateral_unified(
        exposures,
        adjusted_collateral,
        config,
        cp_ead_totals,
        pack=resolved_pack,
    )
overcollateralisation_ratio_expr — src/rwa_calc/engine/crm/expressions.py:131
@cites("PS1/26 Art. 230(1)")
def overcollateralisation_ratio_expr(pack: ResolvedRulepack) -> pl.Expr:
    """Build expression mapping collateral_type to overcollateralisation ratio.

    CRR Art. 230 (Table 5) requires explicit overcollateralisation divisors for
    non-financial collateral (RE/other physical 1.4x, receivables 1.25x).

    PS1/26 Art. 230(1) replaces the CRR step-function with a continuous LGD*
    formula in which the haircut HC is applied multiplicatively at the haircut
    stage; no overcollateralisation divisor is applied for non-financial
    collateral under Basel 3.1. Whether the divisor applies is the regime
    Feature ``firb_overcollateralisation_divisor_applies``; the ratios
    themselves are the regime-invariant ``overcollateralisation_ratios`` lookup.
    """
    if not pack.feature("firb_overcollateralisation_divisor_applies"):
        # PS1/26 Art. 230(1): FCM HC is applied multiplicatively, no
        # overcollateralisation divisor — the ratio is 1.0 for every type.
        return pl.lit(1.0)
    ratios = lookup_float_map(pack.lookup("overcollateralisation_ratios"))
    ct = _coll_type_lower()
    return (
        pl.when(ct.is_in(LIFE_INSURANCE_COLLATERAL_TYPES))
        .then(pl.lit(ratios["life_insurance"]))
        .when(ct.is_in(FINANCIAL_COLLATERAL_TYPES))
        .then(pl.lit(ratios["financial"]))
        .when(ct.is_in(RECEIVABLE_COLLATERAL_TYPES))
        .then(pl.lit(ratios["receivables"]))
        .when(ct.is_in(REAL_ESTATE_COLLATERAL_TYPES))
        .then(pl.lit(ratios["real_estate"]))
        .when(ct.is_in(OTHER_PHYSICAL_COLLATERAL_TYPES))
        .then(pl.lit(ratios["other_physical"]))
        .otherwise(pl.lit(1.0))
    )