Facility Share — Riskiest Member by Applied Approach (Investigation & Proposal)¶
Status: APPROVED 2026-09-05 — mechanism O2 (candidate fan-out, resolved before the floor) in implementation · Created: 2026-09-05 · Decision owner: Phil · Scope: allocation of a shared facility's undrawn commitment to one of its member counterparties, under CRR and under Basel 3.1 with the output floor.
Decisions recorded 2026-09-05 (all as recommended in Section 7): D1 rank on RWA (
rwa_pre_floor),risk_weightfirst tie-break · D2 Basel 3.1 = P2 two-assignment rule by default with afacility_share_metricconfig election to pin P0 · D3 the facility owner is always a member · D4 candidates count toward their own member's obligor aggregates with no window special-casing (revised before implementation — see Section 4) · D5 delete the SA-only preview (P1.359 superseded). Runtime measured 2026-09-05: ~41 µs per extra candidate row through classifier→aggregator; the unsecured-preview alternative (Section 4, O1) was ~20 ms fixed + ~1 µs/candidate — not a deciding factor. Implemented cost (S3 gate): a share-free book pays ~2 ms for the candidate probe at 400k rows; on a synthetic worst case (3,005 shared facilities over 20k counterparties, 7,105 candidates) the aggregator stage runs +83% (106 → 195 ms on the implementer's machine) after a short-circuit that skips both trial evaluations whenever the two assignments already coincide — the residual is the loser drop (~56 ms) and Python-side ranking (~44 ms). The skeptic's independent method on the same synthetic book measured +149% (162 → 405 ms best-of-four), the residual being per-candidate Python work (to_dicts, two sorts per group, audit-frame rebuild); the closure is called zero times there. Accepted as a bounded cost on a book shape the estate does not contain; vectorising the ranking is an optional follow-up. The earlier "~10 ms resolution pass" estimate measured the rank alone and is withdrawn. Retro caveat: the threematerialise_framepassthroughs onsa_results/irb_results/slotting_resultswere removed in S3 (−39 ms);tests/unit/test_aggregator_eager_views.pydoes not list those three fields, so the weakening is unguarded — bounded, because the frames sit on datamaterialise_branchesalready made eager and their only reader outside the aggregator is the audit sink gated onconfig.audit_cache_dir.
This document answers three questions and ends with the decisions the proposal needs and a sequenced way forward:
- What does the calculator do today when a facility is shared, and why is that not "the riskiest member following their respective calculations"?
- Where in the pipeline do the numbers needed for that comparison actually exist?
- How does the Basel 3.1 output floor change what "riskiest" means, and what rule should apply?
0. Summary¶
- Today the winner is chosen in the hierarchy stage by an SA-only preview risk weight built
from
entity_type,cqsandcountry_code. It knows nothing about whether the member is on IRB or SA, its PD/LGD, its exposure class, any CRM on the facility, or the output floor. It is the mechanism P1.307 found choosing a 0%-weighted ECB and a mis-previewed MDB. - The requirement — rank members on their applied approach (IRB formula for IRB members, SA
table for SA members) — cannot be met at the hierarchy stage: approach, class, CRM and the floor
are all decided downstream. The only point where every candidate's own-approach RWA and
SA-equivalent RWA both exist is the exit of the
calculatorsstage. - Proposal (mechanism): compute, then choose. The hierarchy stage emits one undrawn candidate row per member; the candidates flow through classifier, CRM and the calculators as ordinary rows; a new resolution step at the head of the aggregator keeps one row per facility and drops the rest, before the output floor is applied. No second implementation of any pricing logic.
- Basel 3.1 finding: the floor is a portfolio-level
max, so "riskiest" is state-dependent. The engine takes that max over the floor-eligible rows (IRB, slotting, CCR-via-SA) and adds the SA book outside it, so the choice reduces to two global assignments — every share by own-approach RWA (A), or every share by its floored-branch marginal (B:x× SA-equivalent for a modelled member, full RWA for an SA member) — each evaluated end-to-end, keeping the larger. That is a bound rather than an identity, because OF-ADJ moves with the winners' expected loss. A per-row "floored proxy" is provably wrong in both floor states. - Recommendation: metric = own-approach
rwa_pre_floorunder CRR; under Basel 3.1 with the floor applicable, the two-assignment comparison above (P2), with a config election to pin the own-approach rule for firms that prefer stable obligor attribution. Section 7 lists the five decisions needed before design; Section 8 is the phased plan.
1. What the calculator does today¶
1.1 Detection¶
There is no input flag. A facility row carries exactly one counterparty_reference (its owner) and
the facility reference is a unique key (TABLE_UNIQUE_KEYS, data/schemas.py). Loans, contingents
and sub-facilities attach to a facility through facility_mappings
(parent_facility_reference, child_reference, child_type), and each child carries its own
counterparty reference. That is the only place a second counterparty can enter.
engine/hierarchy/facility_undrawn.py::_derive_facility_share_counterparty collects, per root
facility, the distinct counterparty references on all descendant loans, contingents and
sub-facilities (resolved to the root via graph.build_facility_root_lookup). More than one distinct
member ⇒ the facility is a Facility Share.
Two consequences of that rule worth stating plainly:
- The facility's own owner is not a member unless it also has a child exposure. A facility owned
by A whose only mapped loan belongs to B has one member (B), is not a share, and its undrawn
stays with A via the
coalesce(share_counterparty_reference, counterparty_reference)fallback. - Nothing is drawn-weighted. A member with a £1 loan and a member with a £10m loan are equal candidates. That is correct for the stated purpose (any member may draw the whole headroom).
1.2 Allocation¶
Only the synthetic <facility>_UNDRAWN row is affected; drawn legs keep their own counterparties
and the undrawn amount never changes. The candidates are ranked by a non-binding SA-equivalent
risk-weight preview from engine/sa/guarantor_rw.py::build_entity_rw_expr(entity_type, cqs,
country_code); ties break on higher CQS then alphabetical reference; the winner becomes the row's
counterparty_reference and the owner is kept in original_counterparty_reference. Multiple Option
Facility parents (any facility with a child_type='facility' mapping) skip the override because
their per-sub waterfall rows already carry each sub's counterparty. Without a counterparty lookup
carrying entity_type the override is silently skipped.
1.3 Why this is not "riskiest by applied approach"¶
| Gap | Effect |
|---|---|
| SA table only | An IRB member is ranked by the SA weight of its entity type, never by its PD/LGD formula. A low-PD IRB corporate previews at 100% (unrated) and wins over a 75% retail SA member, then is priced at its true IRB weight — possibly far below 75%. Anti-conservative in exactly the case the rule exists for. |
| Entity type + CQS only | No exposure class (the classifier assigns it later), no retail thresholds, no RE/LTV bands, no SME factor, no sovereign/ECB carve-outs (P1.307 fixed two; P1.359 — plain central_bank under Basel 3.1 — is still open). |
| No CRM | Facility-level collateral or a guarantee changes the true RWA of the row; the preview cannot see either. |
| No output floor | Under Basel 3.1 the marginal capital of an IRB member is not its IRB RWA when the floor binds (Section 6). |
| Regime read | The module reads getattr(config, "is_basel_3_1") under a check-17 allowlist entry ("no-pack bootstrap fallback"). A new design must read pack.feature("output_floor"), not the boolean. |
1.4 What the estate contains¶
Measured on 2026-09-05 over the generated fixture parquets (script in Appendix A):
| Measure | Value |
|---|---|
| Fixture directories with facilities + loans + mappings | 9 scanned, 3 skipped (no loan/facility file beside the mapping) |
| Facilities with > 1 distinct child counterparty | 0 |
| Facilities whose single child counterparty ≠ owner | 1 — tests/fixtures/exposures: FAC_RTL_SME_001 owned by RTL_SME_001, its only loan booked to RTL_SME_SCN_001 |
So the only exercised share path is the in-memory portfolio in
tests/acceptance/test_p1_307_facility_share_counterparty_preview.py plus unit pins in
tests/unit/test_hierarchy.py. No golden reporting portfolio contains a share, which means (a) the
reporting goldens will not move when the mechanism changes, and (b) every reporting cell this
path can populate is currently dead (LESSONS B5) — a new portfolio must be registered in RUNS.
2. Regulatory position (premise check)¶
There is no rule for this. Neither CRR nor PS1/26 defines a "facility share" or prescribes how
to attribute a commitment that several obligors may draw. The CCF articles (CRR Art. 111 / Annex I,
Art. 166(8)–(10); PS1/26 Art. 111 Table A1, Art. 166) define the undrawn EAD for a commitment with
one obligor. The riskiest-member rule is therefore firm policy grounded in conservatism
(capitalise the commitment against the worst credit that could draw it), and must be documented in
the methodology as such. It gets no @cites of its own; the EAD it produces cites the CCF articles.
The output floor is regulation, quoted verbatim from ps126app1.pdf (Required Level of Own Funds
(CRR) Part, Article 92, PDF page 13 — see Appendix B):
2A. Subject to paragraph 5, the total risk exposure amount shall be calculated as follows: …
TREA = max {U-TREA; x ∙ S-TREA + OF-ADJ}where …x = 72.5%;OF-ADJ = 12.5 * (IRB T2 – IRB CET1 – GCRA + SA T2).
Paragraph 5 is the transitional phase-in. The engine reads it from the pack Schedule
output_floor_pct (b31 pack; 2027 → 2030 steps) or output_floor_pct_full when the firm elects to
skip the transition; engine/aggregator/_floor.py::apply_floor_with_impact implements the max
at portfolio level and distributes any shortfall pro-rata by sa_rwa across floor-eligible rows
(IRB, slotting, CCR-via-SA — FLOOR_ELIGIBLE_APPROACHES). The floor is a portfolio quantity:
no exposure has "its own" floor; is_floor_binding is the same value on every eligible row.
Engine versus article (premise audit 2026-09-05). apply_floor_with_impact sums U-TREA and
S-TREA over the floor-eligible rows only and re-adds SA rows unscaled (_floor.py:203-225, :326:
total_rwa_post_floor = (U_elig + shortfall) + sa_rwa_total + equity_rwa_total). Art. 92(2A)/(3)/(3A)
take the max over whole-firm totals, in which SA rows appear on both sides. The two agree only at
x = 1; otherwise the engine binds more often and by more (conservative). This design builds to the
engine's formula; the deviation is filed separately in Section 10 and must not be fixed here.
Premise verdict: confirmed, with one rescoping. The brief says "highest risk weight". The conservative purpose is capital, which is RWA = EAD × RW, and EAD can differ by member wherever CCF or CRM depends on the obligor (own-estimate CCFs where permitted, facility-level collateral, a guarantee on one member). The proposal ranks on RWA with RW as the first tie-break; the two orderings coincide whenever EAD is obligor-invariant, which is the common case.
3. Where the numbers live (pipeline facts)¶
Stage (engine/registry.py) |
What is known about a candidate member | Source |
|---|---|---|
hierarchy_resolver |
entity_type, cqs, country_code; model_id via rating inheritance (hierarchy/ratings.py → enrich.py::attach_counterparty_rating) |
joins on the row's counterparty_reference — so the undrawn row's model id follows whichever member it carries |
classifier |
exposure class; approach via classify/permissions.py (model id × class × geography × book) |
still per row, still follows the carried member |
crm_processor |
collateral / guarantee effects on the row | — |
calculators (stages/calc.py) |
calculate_unified runs the SA pipe pre-split only when the output_floor Feature is on (stages/calc.py:99-103): under Basel 3.1 every row carries sa_rwa, under CRR none does and the CRR metric must not read it; the branch calculators then write the own-approach rwa |
contracts/edges.py calc branch edges: rwa, rwa_post_factor, risk_weight, ead_final, approach_applied, sa_rwa |
aggregator |
rwa_pre_floor (= own-approach RWA), sa_rwa, U-TREA, S-TREA, OF-ADJ, is_floor_binding, rwa_final |
_floor.py, OutputFloorSummary |
Two facts follow. First, a preview at the hierarchy stage cannot be made "approach-aware" without re-implementing the classifier's approach ladder, the IRB formula, CRM and the floor — a second home for pricing logic that will drift exactly as the skill-value tables did (the 2026-08-08 graduation). Second, everything the comparison needs already exists per row at the calculators' exit; the only thing missing is more than one row per shared facility.
4. Mechanism options¶
O1 — Approach-aware preview at the hierarchy stage (rejected)¶
Extend _derive_facility_share_counterparty with an IRB-formula preview (member PD from its
rating, facility LGD or supervisory LGD, facility maturity) gated on whether the member has a
permitted model, keep the SA preview otherwise, and add a floor term under Basel 3.1.
- Pro: local to one module; no new rows; no aggregate-window interaction.
- Con: duplicates the approach ladder (
classify/permissions.pyfilters on class, geography and book — none known at hierarchy time), the IRB formula, CRM and the floor. Every P1.307-class divergence multiplies. Still a preview; still wrong wherever the real pipeline disagrees with it. - Con: cannot express the Basel 3.1 answer at all, because the floor state is a portfolio fact.
O2 — Candidate fan-out with post-calculator resolution (recommended)¶
Hierarchy. For each Facility Share, emit one facility_undrawn row per member instead of one
row for the preview winner. Each candidate carries:
| Column | Value |
|---|---|
exposure_reference |
<facility>_UNDRAWN@<member> (MOF suffix grammar unchanged; _exposure_suffix extended) |
source_exposure_reference |
<facility> (unchanged — reconciliation keys on it) |
counterparty_reference |
the member |
original_counterparty_reference |
the facility owner (unchanged semantics) |
facility_share_group |
<facility> (null on every non-share row) |
is_facility_share_candidate |
True |
All candidates carry the full undrawn headroom (each is "as if this member drew it all"). The
existing SA preview and build_entity_rw_expr's only consumer go away (Decision D5).
Classifier → CRM → calculators. No change to any pricing code. Each candidate is classified,
CRM-adjusted and priced as an ordinary row with its own member's class, model permission, PD/LGD and
CRM, and — because the SA pipe runs on every row — its own sa_rwa.
Aggregator, at the top. A new module engine/aggregator/_facility_share.py resolves each
facility_share_group to one winner by the policy metric (Sections 5–6) at the head of
OutputAggregator.aggregate, on the three branch inputs (sa_results, irb_results,
slotting_results) before anything reads them: compute_el_portfolio_summary reads the IRB and
slotting frames directly (aggregator.py:289-292), the securitisation summary and audit are built
off the concatenation at :168-169, and the residual multiplier and floor follow — so filtering the
combined frame alone would leave every losing candidate's EL inside OF-ADJ. It writes an audit frame
facility_share_resolution (one row per candidate: group, member, approach, ead_final,
pre-floor RWA, sa_rwa where present, rank, winner flag, metric used), drops the losers from all
three frames, collapses the winner's exposure_reference back to <facility>_UNDRAWN, and only
then does the existing flow run. The aggregator exit therefore keeps
today's invariant — one undrawn row per facility — so COREP, Pillar 3, reconciliation and the
supervisory register see the same shape they see now, with exposure_reference collapsing back to
<facility>_UNDRAWN on the winner.
Obligor-keyed aggregate windows. Candidates are real rows during classification, so the per-obligor aggregates would see them. The sites are:
| Site | Aggregate |
|---|---|
engine/hierarchy/enrich.py:552-566 |
retail-threshold totals .sum().over(lending_group_reference / counterparty_reference) — partition-local |
engine/classify/subtypes.py:407 |
QRRE obligor aggregate .sum().over("counterparty_reference"), deduped on parent_facility_reference per obligor (:325-380) — partition-local; do not change its key |
engine/classify/attributes.py:703-722 |
Art. 123A(1)(b)(ii) granularity — pl.len().over("counterparty_reference") feeds a portfolio-wide portfolio_total (no .over()); see D4 below |
engine/hierarchy/enrich.py:974, :979 |
short-term rating spill-over max()/min().over("counterparty_reference") — value-idempotent because each candidate carries its member's own rating (verify in a test, do not assume) |
engine/hierarchy/enrich.py:1046, :1051 |
short-term contamination flags max().over("counterparty_reference") — same |
engine/hierarchy/resolver.py:149-166 |
lending_group_totals bundle field (group_by(lending_group_reference) with exposure_count) — inflated by candidates; no engine consumer, three tests assert on it (tests/integration/test_loader_to_hierarchy.py:339, tests/unit/test_hierarchy.py:1224, :3807) |
compute_e_star_group_drawn (CRR Art. 501 E) cannot move: undrawn rows carry
drawn_amount = 0.0 (facility_undrawn.py:503) and E reads drawn plus interest.
Proposed semantics (Decision D4, revised before implementation): no special-casing — every
candidate row counts toward its own member's aggregates exactly as any exposure of that member
would. Each member's lending-group / obligor totals therefore include the full undrawn, which is
the conservative reading of "total amount owed" for a commitment any member may draw. Direction
versus today: the eventual winner's siblings are unchanged (the single undrawn row already counted
for them); a losing member's siblings gain the undrawn in their totals, so the partition-local
thresholds (retail GBP limit, QRRE limit) can only be crossed upward. The Art. 123A(1)(b)(ii)
granularity limb was thought to be an exception and is not — premise refuted by measurement in the
test wave (2026-09-05). The audit derived that each extra candidate inflates the portfolio-wide
portfolio_total (attributes.py:703-722). Measured on the engine: a facility_undrawn row
contributes exactly 0.0 to exposure_for_retail_threshold, because enrich.py builds it from the
drawn amount only ("total amount owed", CRR Art. 123(c) / PS1/26 Art. 123A(1)(b)(i)), and the
denominator divides each obligor's aggregate by that obligor's own line count, so extra rows of the
same obligor and _sa_class leave its term algebraically unchanged. The denominator does not move
with the fan-out in any direction, and the earlier claim that today's denominator "includes the
winner's undrawn" was equally false. No ex-candidate aggregate is built. The one residual, found
in review: a candidate row of a non-retail class joining a retail obligor raises that obligor's
line count without adding to its aggregate, so its contribution falls to L/(L+1) of the aggregate,
the 0.2% limit becomes easier to breach, and the direction is conservative — the opposite of
the audit's premise; an ex-candidate denominator would have been the anti-conservative move. The
invariance is pinned by tests/unit/classifier/test_facility_share_granularity_denominator.py,
whose adequacy assertion proves a 0.1% denominator move would be visible. A second, narrow path exists and is not measured by
the scenario (Section 10):
a member demoted out of QRRE by the extra aggregate has correlation 0.03 + 0.13·exp(-35·PD),
which falls below QRRE's fixed 0.04 above PD ≈ 7.3%, so for such an obligor demotion lowers RWA;
it is partition-local (touches only that member) and is disclosed in the methodology page.
The rejected alternative — "own-inclusive", where non-candidate rows see no candidate — is exact
for losers but drops the undrawn from the winner's siblings, which is RWA-reducing relative to today.
MOF parents keep today's exclusion: their waterfall rows already carry each sub's counterparty. The residual row (parent's own risk type) is not a candidate. Revisit only if a portfolio needs it.
Errors. Classification/CRM warnings raised on a losing candidate are noise; the resolution step
suppresses (or re-keys to the group) any CalculationError whose exposure_reference belongs to a
dropped candidate.
Edge contracts. The two new columns must be declared on nine contracts in
contracts/edges.py: HIERARCHY_RESOLVED_EDGE (:882), HIERARCHY_EXIT_EDGE (:888),
CLASSIFIER_EXIT_EDGE (:1187), CRM_EXIT_EDGE (:1364), RE_SPLIT_EXIT_EDGE (:1396),
SA_BRANCH_EDGE (:1769), IRB_BRANCH_EDGE (:1785), SLOTTING_BRANCH_EDGE (:1846), and
AGGREGATOR_EXIT_EDGE (:1868) for facility_share_group on the surviving winner row.
EdgeContract.conform (edges.py:336-341) drops an undeclared column with no error and no
warning, so one missed edge turns the feature into a green no-op (LESSONS B1). A contract test
must assert both columns are declared on every edge in that chain. The only exposure_reference
de-dup in src/ is classify/permissions.py:261; the @<member> suffix keeps candidates distinct
through it.
Cost. Rows added = Σ(members − 1) over Facility Shares. That is not "a handful" on a book with
many multi-borrower facilities: measured on the 20k-counterparty benchmark generator (S2 gate,
2026-09-05) the fan-out adds 4,100 candidate rows, 5.6% more exposures through classifier, CRM and
the calculators. The hierarchy stage nevertheless ran 35% faster after S2 (0.221 s vs 0.342 s,
best of three), because the deleted SA preview cost more than the new join; the per-row cost
downstream is the ~41 µs measured in the header. No new materialisation; the resolution is a rank
per facility_share_group plus a filter on the three branch frames.
O3 — Shadow second pass (rejected)¶
Run the pipeline once as today, then re-run classifier → calculators on the candidate rows only with
the first pass's aggregates as context, and patch the winner in. Exact, but it needs stages to be
re-entrant on a sub-frame with injected context, which the fold (engine/orchestrator.py) does not
support, and it doubles the plan depth on a path already near the single-lazy-plan ceiling.
5. The ranking metric under CRR¶
Under CRR the output_floor Feature is off and capital is additive, so the capital-maximising
choice is per group:
winner(g) = argmax_{i ∈ members(g)} rwa_pre_floor_i # own approach, post supporting factor
tie-break: risk_weight desc → pd_floored / cqs desc (worse credit) → counterparty_reference asc
rwa_pre_floor is the calculators' own-approach RWA including the SME / infrastructure supporting
factors (Art. 501 / 501a), so an SME member's relief is honoured — the metric is capital, not a
gross weight. Ranking on risk_weight instead is a one-line change of sort key; the proposal
recommends RWA (Section 2) and records the choice as Decision D1.
6. The ranking metric under Basel 3.1 — the output floor¶
6.1 Why the floor changes the answer¶
Let the non-share book contribute U0 / S0 to the floor-eligible totals and SA0 to the SA book.
For a Facility Share g with members i, let u_i be the member's own-approach pre-floor RWA and
s_i its SA-equivalent (sa_rwa, present only under Basel 3.1). An assignment c picks one member
per group. With the engine's formula (Section 2), a modelled member's u_i/s_i enter U_elig/S_elig
and an SA member's u_i enters the SA book outside the max:
The marginal capital of a member is u_i when the floor does not bind; when it binds it is x·s_i
for a member on a floor-eligible approach and still u_i for an SA member. Those can rank members
differently. Worked micro-example (symbolic x; the pack Schedule gives x for the reporting year):
| Member | approach | u_i |
s_i |
marginal, floor NOT binding | marginal, floor binding |
|---|---|---|---|---|---|
| A | IRB corporate, low PD, high SA-equivalent | 30 | 150 | 30 | 150·x |
| B | IRB corporate, higher PD | 80 | 80 | 80 | 80·x |
| C | SA retail (owner) | 75 | 75 | 75 | 75 |
Not binding ⇒ B is riskiest. Binding ⇒ A is riskiest at every Schedule step (150x ≥ 90 > 75 > 80x).
An SA member wins under a binding floor only when its full RWA exceeds every modelled member's
x·s_i. A rule that does not know the floor state picks the wrong member in one of the two states.
6.2 The reduction to two assignments¶
TREA(c) is the maximum of two branches. The un-floored branch F1(c) = SA(c) + U_elig(c) is
additive over groups, so it is maximised exactly by A = "every group by argmax u_i". The
floored branch F2(c) = SA(c) + x·S_elig(c) + OF-ADJ(c) has per-member marginal
b_i = x·s_i (floor-eligible approach) or u_i (SA approach); B = "every group by
argmax b_i". When OF-ADJ is constant, F2 is additive too and
so the capital-maximising assignment is one of exactly two, found by evaluating the floored total
twice. OF-ADJ is not constant (premise audit): the GCRA cap is 1.25% of S_elig
(compute_of_adj, live only when config.output_floor.gcra_amount > 0, shipped default 0), and IRB
T2 / IRB CET1 come from compute_el_portfolio_summary(irb_results, slotting_results)
(aggregator.py:289-292, :317-320), so the winning modelled member's expected loss feeds them.
The floored branch stays strictly increasing in S (slope x − 12.5·0.0125 ≥ 0.44375 while the
cap binds, x above it), so argmax b_i still maximises it group by group, but the branch is no
longer additive and the reduction is a bound, not an identity: max(TREA(A), TREA(B)), each
evaluated end-to-end with the EL summary of that assignment, is exact for those two assignments and
a lower bound on the true optimum. The EL channel is bounded by
12.5·(PD_i·LGD_i + PD_j·LGD_j)·EAD against an S channel of x·RW_SA·EAD, and dominates only above
PD·LGD ≈ 0.058 at full x and 100% SA weight. x comes from
_output_floor_pct(pack, config.output_floor, reporting_date) exactly as the aggregator reads it.
6.3 Policy options¶
| Rule | Capital-maximising? | Local & stable? | Notes | |
|---|---|---|---|---|
| P0 | own-approach u_i (same as CRR) |
only when the floor does not bind | yes | The "obligor risk" reading: assignment is a classification decision; the floor is applied afterwards at the level the rule prescribes. Under-capitalises the share when the floor binds and an IRB member's SA-equivalent is the largest. |
| P1 | per-row proxy max(u_i, x·s_i) |
no — wrong in both states (A scores max(30,100x), B scores 75: picks B when 100x < 75, but if the floor binds A was riskier; picks A when 100x > 75 even when the floor does not bind and B was riskier) |
yes | Tempting because it looks like "a floored RW". A per-exposure floor is not how Art. 92(2A) works. Rejected. |
| P2 | evaluate TREA(A) and TREA(B) end-to-end, apply the larger |
yes for the two evaluated assignments (exact when OF-ADJ is constant; a bound when its EL channel is live) | no — the assignment can flip with the floor state, with the phase-in step (output_floor_pct moves 2027→2030), and between reporting scopes (the floor is per entity of application; resolve_scope runs per scope) |
The "riskiest member" under the floor. TREA(P2) ≥ TREA(A) always; versus today's preview winner it is not guaranteed when the floor binds, by at most the EL-channel bound. Attribution flips are visible in obligor-level COREP (C 08.x) and reconciliation. |
| P3 | floored-branch marginal b_i always |
only when the floor binds | yes | Right whenever the floor binds; wrong otherwise. No better than P0 in principle, and it contradicts the CRR rule for the same firm. |
6.4 Recommendation¶
- CRR: P0 (Section 5).
- Basel 3.1, floor applicable (
pack.feature("output_floor")on andconfig.output_floor.is_entity_in_scope()— the gate the engine composes ataggregator.py:309; notis_floor_applicable(), which duplicates the regime Feature in a config boolean): P2, implemented as: rank every group under both metrics (u_i, thenb_i), evaluate each assignment end-to-end with the aggregator's own functions — EL summary, OF-ADJ and floor computed on that assignment's resolved frames — and keep the assignment with the largertotal_rwa_post_floor(ties → A, the own-approach assignment, for stability). Record which won inOutputFloorSummary(facility_share_metric_used,facility_share_trea_alternative) so the flip is never silent. - Basel 3.1, floor not applicable (Art. 92(2A)(b)–(d) exemptions): P0.
- Firm election: a
facility_share_metricfield onCalculationConfig("floor_aware"default,"own_approach"to pin P0) — a firm election belongs in config, likeOutputFloorConfig.skip_transitional, not in the pack; the regime gate is the existingoutput_floorFeature (check 17).
Why P2 over P0 as the default: the stated purpose of the rule is conservatism, and under Basel 3.1
the firm's capital is TREA, not U-TREA. The estate already accepts portfolio-state-dependent
per-exposure numbers (floor_impact_rwa is pro-rata of a portfolio shortfall); P2 makes the
attribution depend on the same state, which is a visible but honest consequence. The election
exists for firms whose reporting or reconciliation cannot tolerate attribution moving with the floor.
Free by-product. Because every candidate is priced, the audit frame is a per-facility allocation sensitivity table (RWA under every member, both metrics). That is directly useful against the legacy system in reconciliation, whose own share rule is unknown.
7. Decisions required before design¶
| # | Decision | Recommendation |
|---|---|---|
| D1 | Rank on RWA (rwa_pre_floor) or on RW (risk_weight)? |
RWA, RW as first tie-break (Section 2). |
| D2 | Basel 3.1 policy: P0 / P2 / P3? Default of the election? | P2 default, P0 election (Section 6.4). |
| D3 | Is the facility owner always a member? | Yes. The owner is the legal borrower and can draw. This turns FAC_RTL_SME_001 in tests/fixtures/exposures into a two-member share — measure that fixture's consumers with the full unit suite before relying on any green (LESSONS D2). |
| D4 | Candidate rows in obligor aggregates: count each candidate toward its own member's partition-local totals with no window special-casing anywhere (Section 4)? | Yes — conservative at every site; the granularity-denominator exclusion first added to this decision was withdrawn 2026-09-05 when measurement showed undrawn rows contribute 0.0 to the retail-threshold aggregate. Record it in the methodology page with the high-PD QRRE residual. "Own-inclusive" rejected as RWA-reducing for the winner's siblings; a second pass is O3. |
| D5 | Delete the SA preview (build_entity_rw_expr and its _share_* plumbing)? |
Yes — it has one consumer and the fan-out replaces it. P1.359 becomes superseded; P1.358 is the guarantor path and is unaffected. |
8. Way forward¶
Sized for /next-items; the aggregator and contracts/edges.py are shared files, so S3 is a
single-stream item. Suggested bullet IDs are placeholders for plan-curator (next free is
P1.367).
| Step | Deliverable | Gate |
|---|---|---|
| S0 | Decisions D1–D5 recorded at the top of this page. | — |
| S1 — fixtures & failing tests (TDD) | An in-memory reporting portfolio tests/fixtures/facility_share_portfolio.py::build_facility_share_bundle(variant) returning a RawDataBundle via make_raw_bundle (the shape every RUNS entry consumes — not a parquet directory, no generate_all.py entry; corrected 2026-09-05 after the Wave 1 review). One shared facility with three members — the SA owner at CQS 2, an SA member at CQS 1, an F-IRB member with a low internal PD (so u_IRB < u_SA < x·s_IRB, the divergence the floor-aware rule needs) — a solo facility as control, and an IRB anchor loan whose PD is the single input that makes the floor bind in one variant and not the other (the two-leg pattern from LESSONS B5). Register all four regime×variant runs in RUNS with an explicit enforce_retail_granularity=True on the Basel 3.1 arm and a prior-period config so C 08.04 is emitted. Every asserted number is derived in-test from statistics.NormalDist plus pack values, never transcribed. Oracle case pins per-candidate pricing only (the oracle drivers bypass hierarchy, classifier and CRM). Unit tests for the resolver on synthetic frames: ties, the all-non-finite fallback, argmax s_i vs argmax b_i, and slotting / standardised_ccr candidates in the b_i predicate. Named mutation detectors per C1.11. Scenario of record: .claude/state/fs1-scenario-proposal.md. |
red before S2/S3 |
| S2 — hierarchy fan-out (P1.367) | Candidate rows, the two new columns, owner-as-member, no aggregate-window changes (the granularity exclusion was withdrawn — see Section 4), the nine edge declarations plus a contract test that pins them, preview deletion with its footprint (S5), rulepack threaded into facility_undrawn.py so the check-17 allowlist entry retires; tests pin that each candidate contributes to its own member's partition-local totals and that the short-term spill-over windows are value-idempotent. |
arch_check 16/17/18/21 green; full tests/unit (column footprint changes — LESSONS D2) |
| S3 — aggregator resolution (P1.368, single-stream) | engine/aggregator/_facility_share.py called at the head of aggregate() on the three branch frames: metric per policy, TREA(A)/TREA(B) evaluated end-to-end, loser drop from all three frames, winner reference collapse, error re-key, facility_share_resolution audit frame on AggregatedResultBundle, summary fields, facility_share_metric config election. |
Tier 2 mandatory: tests/oracle + tests/acceptance/reporting; coverage_report.py --check (cells newly live ⇒ --update-baseline, re-measured) |
| S4 — docs (Tier 5) | docs/architecture/components.md hierarchy + aggregator rows; a methodology page docs/specifications/facility-share-allocation.md stating the policy, the two-assignment rule and D4; changelog. The basel31/crr skills get a mechanics paragraph only — no values. |
uv run zensical build; check_doc_links.py --check |
| S5 — close out | Retire P1.359 as superseded (its Ev: path is already stale). D5 deletion footprint: build_entity_rw_expr has one production consumer (facility_undrawn.py:986) but deleting it means regenerating tests/contracts/data/citation_snapshot.json (7 entries) before the suite, tests/contracts/data/confidence_snapshot.json (7 references — its evidence layer text-scans the test tree) and docs/development/citation-matrix.md; correcting docs/specifications/crr/sa-risk-weights.md:274-283; retiring the check-17 allowlist entry for facility_undrawn.py; and re-pointing the defect-pinning tests that assert the old SA-preview winner — tests/unit/test_entity_rw_preview.py (whole file), tests/unit/test_hierarchy.py :6379, :6475 (rename — under D3 it is a two-member share), :6674, :6780 and the eight test_p1_307_share_*, and tests/acceptance/test_p1_307_facility_share_counterparty_preview.py :292, :323, :351, :375. Add the resolution rule to the reconciliation docs (sensitivity table). |
stress suite before PR |
Effort: S1 M · S2 M · S3 M · S4 S — L overall, two batches.
9. Risks and traps (from .claude/LESSONS.md)¶
- D1 — every
engine/sa/transform is a floor consumer. Candidates'sa_rwaare real S-TREA contributions until the losers are dropped; the drop must happen beforeapply_floor_with_impactor S-TREA is inflated by every losing candidate. - D2 — measure blast radius with the full unit suite. Owner-as-member (D3) changes an existing fixture; the aggregate-window change alters a conditional expression's column footprint.
- D3 — edge dtype violations redden the whole acceptance suite. Two new columns on nine edges.
- B1 — an undeclared column is dropped silently by
EdgeContract.conform. A missed edge makes the resolver a no-op and every test that only checks "one row per facility" stays green. Pin the declarations in a contract test and assert the audit frame has one row per candidate. - Four-frame input.
aggregate()receives the three branch frames separately and the EL summary reads two of them directly; resolve on all three at the head, never oncombinedalone. - C7 — both regimes, shown red separately. The metric differs by regime by design; a both-regimes parametrisation that is red only under Basel 3.1 proves one regime.
- B5 — dead cells. No golden portfolio has a share today. Registration in
RUNSis necessary; the floor-binding/non-binding pair is what makes the cell move. - Check 21. D4 adds no window. If a later change does special-case candidates in an aggregate,
it must be two
with_columns, never a window whose input is another window. - LOC ratchet.
max_engine_module_locis 1709 (crm/guarantees.pyat 1708 binds);facility_undrawn.pyis 1009 andaggregator/aggregator.pyshould not grow — hence the new_facility_share.pymodule.engine_fill_null_sites(468) also binds: reuse predicates. - Attribution flips under P2 are a feature, not a bug — but they must be surfaced in the summary and the audit frame, never inferred from a moved COREP row.
10. Findings out of scope (filed for Phil, not fixed here)¶
- The engine floors the modelled subset, not the firm.
apply_floor_with_impactcomputesmax(U_elig, x·S_elig + OF-ADJ) + SA + equity(_floor.py:203-225,:309,:326; the comment at:192-193"SA exposures cancel out" holds only atx = 1). Art. 92(2A)/(3)/(3A) take the max over whole-firm totals. Example atx = 0.725:U_irb = 50,S_irb = 100,SA = 100— articlemax(150, 145) = 150, enginemax(50, 72.5) + 100 = 172.5. Conservative today; fixing it is RWA-reducing and needs its own plan bullet, evidence and sign-off (LESSONS A2, D1). Every reporting golden and the supervisory register bank the current behaviour. InstitutionTypehas no member for a ring-fenced body outside a sub-consolidation group (domain/enums.py:622-624), which Art. 92(2A)(a)(i) says is floored on an individual basis. Reachability not established.- QRRE demotion at high PD lowers RWA (Section 4, D4) — not measurable by the S1 scenario,
which has no retail obligor (adding one breaks the divergence chain the floor-aware test needs).
The QRRE obligor aggregate dedupes on
parent_facility_referenceper obligor and picks upfacility_limitonce per member (Site A coupling), so a candidate does move a losing member's QRRE aggregate even though it moves no retail-threshold aggregate. Owed coverage, to be filed as a Tier 1 bullet: a share with a revolving retail member near the QRRE limit and a PD above the ≈7.3% crossover. (The Art. 123A granularity-denominator fixture once listed here is no longer owed — the premise was refuted by measurement and the invariance is pinned; see Section 4.) A candidate-aware QRRE aggregate would need the obligor's PD at classification time. - The Art. 110A due-diligence input never reaches the SA branch (found by the fixture wave,
2026-09-05).
due_diligence_performedanddue_diligence_override_rware accepted byLOAN_SCHEMA/CONTINGENTS_SCHEMAbut are not declared incontracts/edges.py::_hierarchy_resolved_columns, soEdgeContract.conformdrops them silently before classification; the guard atengine/sa/rw_adjustments.py:599tests column presence, soSA004fires on every Basel 3.1 run regardless of input and the override risk weight is unreachable. LESSONS B1 shape. Needs its own Tier 1 bullet (declare the columns on the hierarchy edges, test the override end-to-end); the FS-1 tests filterSA004by code meanwhile. - The output-floor add-on has no reported home in the IRB templates (found by the test wave,
2026-09-05, when the FS-1 portfolio became the first registered run with a binding floor).
Supervisory rules
boe_b0751(OF08.01.01.01) andboe_b0763(OF08.02.01.01) — one published identity restated per template, LESSONS C6 — require RWEA after adjustments (col 0260) to equal the sum of RWEA before adjustments, PMAs, unrecognised-exposure adjustments and the mortgage RW-floor adjustment (cols 0251–0254). Onb31/facility-share-bindingcol 0260 exceeds that sum by exactlyOutputFloorSummary.shortfall(453,708.71), because the pro-rata floor add-on lives insiderwa_finaland the reporting projection mirrors it into col 0260. Every prior registered portfolio had a non-binding floor, so the rules had never fired (LESSONS B5, fourth form: a state no fixture reached). Both breaks are ERROR severity. Direction of the fix is a reporting basis decision — un-floored RWEA in the IRB templates with the floor carried at the total level (OF 02.01 / C 02.00), or a new adjustment column — and it is not made in this batch. The breaks are banked invalidation_known_breaks.jsonwith anOWNER:token pointing at the new Tier 1 bullet so the shrink-only register stays honest.
Appendix A — estate measurement¶
scan_shares.py (run 2026-09-05 from the main checkout; joins each fixture directory's
facility_mapping(s).parquet to its loan(s) / contingent(s) / facility|facilities parquets and
counts distinct child counterparties per parent facility):
exposures: shares=0 owner_mismatch_single_member=1
[{'fac': 'FAC_RTL_SME_001', 'owner': 'RTL_SME_001', 'counterparty_reference': 'RTL_SME_SCN_001'}]
scanned=9 dirs_with_findings=1 skipped=3
skipped: p1_118
skipped: p1_142
skipped: p1_151
Appendix B — PS1/26 Art. 92(2A), verbatim (ps126app1.pdf, 0-indexed page 12, printed "Page 13 of 492"; 2A(b)-(d) on 0-indexed page 13; 3A and paragraph 5 on 0-indexed page 14)¶
2A. Subject to paragraph 5, the total risk exposure amount shall be calculated as follows:
(a) for the purposes of complying with the obligations of this Part:
(i) on an individual basis, a stand-alone institution in the UK and a ring-fenced body that
is not a member of a sub-consolidation group;
(ii) on a sub-consolidated basis, a ring-fenced body that is a member of a sub-consolidation
group; and
(iii) on the basis of its consolidated situation, a CRR consolidation entity that is not an
international subsidiary,
shall calculate the total risk exposure amount as follows:
TREA = max {U-TREA; x ∙ S-TREA + OF-ADJ}
where:
TREA = the total risk exposure amount of the entity;
U-TREA = the un-floored total risk exposure amount of the entity calculated in accordance
with paragraph 3;
S-TREA = the standardised total risk exposure amount of the entity calculated in accordance
with paragraph 3A;
x = 72.5%;
OF-ADJ = 12.5 * (IRB T2 – IRB CET1 – GCRA + SA T2);
...
GCRA = general credit risk adjustments, gross of tax effects, of up to 1.25% of
risk-weighted exposure amounts calculated in accordance with paragraph 3A;
Related pages: Architecture components · Implementation plan · Escape log