Risk-scoring algorithm
The composite risk score is a 0-100 number computed per AR from five normalised inputs, with weights tunable per principal firm. In the demo, the score is pre-baked into fixtures (see lib/fixtures.ts) and recomputed only when the AR-side surface adds a new MI return. In production, the score is recomputed nightly and on every event ingest that touches an input (breach filed, file review completed, MI return submitted).
The reference implementation is lib/risk-scoring.ts. Inputs are normalised to [0, 1]; weights sum to 1.0; the formula multiplies and scales to [0, 100].
Inputs
Section titled “Inputs”Each input is a number in [0, 1].
complaintsDensity. Uphold-weighted complaints over rolling 12 months, divided by new-business volume in the same window. Normalised by clamping at the 95th-percentile firm-wide rate.breachSeveritySum. Weighted sum of breach severities in rolling 12 months. Severity weightsminor=1, moderate=3, material=8, significant=20. Customer-impact multipliernone=1.0, potential=1.2, actual-low=1.5, actual-high=2.0. Normalised by clamping at 60.fileReviewInverse.1 - (mean review score / 100)over the rolling 12-month review history, weighting recent reviews twice. ARs with fewer than two reviews in the window get the firm-wide median imputed, with a flag.timeSinceLastReview. Months sincelastAnnualReviewAt, capped and normalised against the firm’s review cadence policy (default 12 months, cap 24).miAnomalyScore. The most recent MI return’sanomalyScore, computed as a z-score against the AR’s own 8-quarter trailing distribution, squashed throughtanhand rescaled to[0, 1].
Default weights
Section titled “Default weights”export const DEFAULT_WEIGHTS: RiskScoreWeights = { complaints: 0.20, breach: 0.30, reviewInverse: 0.25, timeSinceReview: 0.10, miAnomaly: 0.15,};Weights sum to 1.0. The product default leans on breach weight and file-review weight because those are the two inputs the FCA most directly tests when reviewing supervisory adequacy under PS22/11.
Formula
Section titled “Formula”composite = 100 * ( w.complaints * complaintsDensity + w.breach * breachSeveritySum + w.reviewInverse * fileReviewInverse + w.timeSinceReview * timeSinceLastReview + w.miAnomaly * miAnomalyScore)Reference implementation:
export interface RiskScoreInputs { complaintsDensity: number; breachSeveritySum: number; fileReviewInverse: number; timeSinceLastReview: number; miAnomalyScore: number;}
export interface RiskScoreWeights { complaints: number; breach: number; reviewInverse: number; timeSinceReview: number; miAnomaly: number;}
export function computeRiskScore( inputs: RiskScoreInputs, weights: RiskScoreWeights = DEFAULT_WEIGHTS,): number { return ( 100 * (weights.complaints * inputs.complaintsDensity + weights.breach * inputs.breachSeveritySum + weights.reviewInverse * inputs.fileReviewInverse + weights.timeSinceReview * inputs.timeSinceLastReview + weights.miAnomaly * inputs.miAnomalyScore) );}export function bandFromScore(score: number): RiskBand { if (score < 20) return "low"; if (score < 40) return "moderate"; if (score < 60) return "elevated"; if (score < 80) return "high"; return "critical";}| Band | Range | UI colour token |
|---|---|---|
| Low | 0 to 19.99 | --severity-low |
| Moderate | 20 to 39.99 | --severity-moderate |
| Elevated | 40 to 59.99 | --severity-elevated |
| High | 60 to 79.99 | --severity-high |
| Critical | 80 to 100 | --severity-critical |
Worked example
Section titled “Worked example”An AR at Heritage Mortgage Network. Reference date: 2026-05-08.
| Input | Calculation | Normalised |
|---|---|---|
complaintsDensity | 2 upheld complaints over £4.2m new business in 12 months. AR rate 2 / 4.2 = 0.476 per £m. Firm 95th-percentile rate 0.6 per £m. Clamp 0.476 / 0.6 | 0.79 |
breachSeveritySum | One material breach (8) at actual-low impact (x1.5) = 12. One minor breach (1). Sum 13. Clamp 13 / 60 | 0.22 |
fileReviewInverse | Recent-weighted mean review score 70/100. 1 - (70 / 100) | 0.30 |
timeSinceLastReview | 14 months since last annual review against 12-month cadence, cap 24. min(14 / 24, 1) | 0.58 |
miAnomalyScore | Most recent MI return z-score squashed through tanh | 0.41 |
composite = 100 * ( 0.20 * 0.79 + 0.30 * 0.22 + 0.25 * 0.30 + 0.10 * 0.58 + 0.15 * 0.41)= 100 * (0.158 + 0.066 + 0.075 + 0.058 + 0.0615)= 41.85Score 41.85 lands in the elevated band.
Tunability UI (production)
Section titled “Tunability UI (production)”A tenant settings panel “Risk model” is restricted to principal-admin. Five sliders match the formula, each anchored to its component name and the firm-wide median value of that input.
Auto-rebalance
Section titled “Auto-rebalance”Weights must sum to 1.0 at all times. Dragging one slider proportionally shrinks the other four. A “lock” pin per slider excludes it from rebalancing, so an admin can fix one weight and let the rest absorb the change.
flowchart LR drag[Admin drags<br/>w.breach to 0.40] --> calc[Compute delta = +0.10] calc --> excl{Locked sliders?} excl -->|none| split[Split -0.10 across<br/>4 unlocked sliders<br/>weighted by current value] excl -->|some| split2[Split -0.10 across<br/>unlocked sliders only] split --> apply[Apply, sum = 1.0] split2 --> applyBacktest
Section titled “Backtest”A 90-day backtest panel sits below the sliders. As the admin moves a slider, the panel re-runs computeRiskScore against historic snapshots and shows the resulting band-distribution shift as a stacked-bar diff (before / after) for the firm’s full AR set, alongside the count of ARs that would change band under the new weights.
Saving
Section titled “Saving”Saving writes a RiskWeightChange audit event with oldWeights and newWeights vectors and triggers a full recompute job. Old snapshots are immutable; recomputed scores are written as new history rows so the AR’s trajectory chart shows a band shift at the change date with a tooltip linking to the audit event.
// Audit payload{ action: "tenant.risk-weights.update", subjectType: "tenant", subjectId: tenantId, meta: { oldWeights: { complaints: 0.20, breach: 0.30, reviewInverse: 0.25, timeSinceReview: 0.10, miAnomaly: 0.15 }, newWeights: { complaints: 0.15, breach: 0.40, reviewInverse: 0.25, timeSinceReview: 0.10, miAnomaly: 0.10 }, affectedArCount: 23, bandShifts: { up: 14, down: 9, unchanged: 78 }, },}Guard rails
Section titled “Guard rails”- Weights below
0.05require a confirmation dialog (“This component will have a negligible effect on the score”). - Weights above
0.50trigger a “single-factor risk” warning (“Score will be dominated by this component, consider rebalancing”). - Components cannot be removed; the formula always carries all five inputs.
Why step-and-annotate, not retro-rewrite
Section titled “Why step-and-annotate, not retro-rewrite”Changing weights does not rewrite historic scores in place. The trajectory chart shows a visible step at the change date, with a tooltip pointing to the audit event. Auditors and the FCA can reconstruct what the score was under the prior model and what it became under the new one. Retro-rewriting would erase the firm’s own supervisory record of the change and break the audit chain’s narrative.
Recompute triggers
Section titled “Recompute triggers”| Event | Recompute scope |
|---|---|
| Breach filed or status changed | The AR’s score |
| File review completed | The AR’s score |
| MI return submitted | The AR’s score |
| Annual review signed off | The AR’s score (resets timeSinceLastReview) |
| Risk-weights change | Every AR in the tenant |
| Nightly | Every AR in every tenant |
| Manual trigger by admin | Tenant-wide or single AR |
The nightly job is the canonical source. Event-triggered recomputes are an optimisation so the UI does not lag.