CoolFace
Apppublic

mihir2007/Cyber-Risk

sourceHugging Faceupdated 16d agoView on Hugging Face
0likes
quant_engine.py627 linesDownload Raw Back to root
1"""2quant_engine.py3---------------4AIML Probability & Financial Impact Engine.5 6Pipeline7--------81. Likelihood calibration: converts (CVSS, EPSS, attack-graph exposure,9   active control efficacy) into a calibrated annual event frequency (λ)10   per asset via a logistic-sigmoid link function.112. Financial impact modeling: builds a per-event composite loss distribution12   from outage cost, data-breach cost, Indian regulatory penalties13   (DPDPA 2023, RBI CSF, SEBI CSCRF), and forensics/incident-response cost.143. Monte Carlo simulation: a vectorized compound Poisson process (Poisson15   frequency x per-event severity) run for 10,000 iterations per asset,16   from which Expected Annual Loss (EAL), Value at Risk (VaR 95 / 99), and17   a loss exceedance curve are derived, then persisted to `SimulationRun`.18 19All monetary values are in Indian Rupees (₹).20"""21 22from __future__ import annotations23 24import json25import math26from dataclasses import dataclass, field27 28import numpy as np29from scipy import stats30from sqlalchemy.orm import Session31 32from graph_engine import (33    AssetGraphExposure,34    AssetLineageProfile,35    AttackGraphEngine,36    build_exposure_map,37)38from models import Asset, SecurityControl, SimulationRun39 40# --------------------------------------------------------------------------- #41# Global calibration constants42# --------------------------------------------------------------------------- #43N_ITERATIONS = 10_00044RNG_SEED = 202445 46# Logistic-sigmoid likelihood calibration: p = sigmoid(k * (score - x0)).47# `score` combines CVSS (0-10), 10*EPSS (0-10), and 10*GraphWeight (0-10)48# onto a comparable 0-30 raw scale, plus a small per-extra-vulnerability49# bonus (see `calibrate_asset_annual_frequency`). x0/k are centered so that50# only a genuinely severe, highly-exploitable, internet-exposed, multi-CVE51# posture pushes the sigmoid near saturation -- avoiding every Critical52# asset being (unrealistically) treated as certain to be breached.53SIGMOID_K = 0.3054SIGMOID_X0 = 20.055 56# Ceiling on realistic annual breach *event* frequency by asset tier, applied57# as a multiplier on the sigmoid-calibrated exploit probability to produce58# the final Poisson rate (lambda). This bounds even a maximally exploitable,59# maximally exposed Critical asset to a plausible worst-case number of60# successful compromises per year, rather than letting compounded61# probabilities imply near-continuous breach activity.62TIER_MAX_ANNUAL_FREQUENCY = {"Critical": 4.0, "Medium": 1.5, "Low": 0.5}63 64# Residual/zero-day risk applied even to assets with no *currently known,65# unpatched* CVEs -- no real system has zero risk.66BASELINE_EXPLOIT_PROBABILITY = 0.0267 68# Small additive bonus to the combined raw score per extra unpatched69# vulnerability beyond the first, modeling increased attack surface without70# re-introducing runaway multiplicative compounding across many CVEs.71PER_EXTRA_VULN_SCORE_BONUS = 0.572 73# Indian regulatory constants (₹)74DPDPA_MAX_PENALTY_INR = 2_500_000_000.0  # ₹250 Crore75RBI_SEBI_FINE_MIN_INR = 1_000_000.0  # ₹10 Lakh76RBI_SEBI_FINE_MAX_INR = 10_000_000.0  # ₹1 Crore77FORENSICS_COST_MIN_INR = 1_500_000.0  # ₹15 Lakh78FORENSICS_COST_MAX_INR = 3_500_000.0  # ₹35 Lakh79 80PII_RECORD_COST_INR = 1_200.081FINANCIAL_RECORD_COST_INR = 2_500.082 83# Reportable-incident downtime threshold (hours) above which RBI/SEBI84# incident-reporting fines become applicable for regulated assets.85REPORTABLE_DOWNTIME_HOURS = 2.086 87LOSS_EXCEEDANCE_RETURN_PERIODS = (10, 50, 100)88 89 90# --------------------------------------------------------------------------- #91# Helper: Beta-PERT sampling92# --------------------------------------------------------------------------- #93def _pert_rvs(94    min_val: float, mode: float, max_val: float, size: tuple[int, ...], rng: np.random.Generator95) -> np.ndarray:96    """97    Draws samples from a Beta-PERT distribution parameterized by98    (minimum, most-likely, maximum), a standard choice for expert-elicited99    risk quantities (e.g. downtime hours, breach-record fractions) where a100    full historical loss dataset is unavailable.101 102    The PERT distribution is a reparameterized Beta distribution with103    shape parameters derived from the three-point estimate:104        alpha = 1 + 4 * (mode - min) / (max - min)105        beta  = 1 + 4 * (max - mode) / (max - min)106    """107    if max_val <= min_val:108        return np.full(size, min_val, dtype=float)109    alpha = 1.0 + 4.0 * (mode - min_val) / (max_val - min_val)110    beta = 1.0 + 4.0 * (max_val - mode) / (max_val - min_val)111    unit_samples = stats.beta.rvs(alpha, beta, size=size, random_state=rng)112    return min_val + unit_samples * (max_val - min_val)113 114 115# --------------------------------------------------------------------------- #116# Likelihood calibration117# --------------------------------------------------------------------------- #118def _sigmoid(x: np.ndarray | float) -> np.ndarray | float:119    return 1.0 / (1.0 + np.exp(-x))120 121 122def _vulnerability_raw_score(cvss_score: float, epss_score: float, graph_weight: float) -> float:123    """124    Combines CVSS (0-10), 10*EPSS (0-10), and 10*GraphWeight (0-10) onto a125    comparable 0-30 raw severity/exposure scale, prior to the sigmoid link.126    """127    return cvss_score + 10.0 * epss_score + 10.0 * graph_weight128 129 130def calibrate_vulnerability_exploit_probability(131    cvss_score: float,132    epss_score: float,133    graph_weight: float,134    active_control_efficacies: list[float],135) -> float:136    """137    Computes the calibrated probability that a single, isolated vulnerability138    would be exploited in a given attack attempt, using the logistic-sigmoid139    link function:140 141        p = sigmoid(k * (CVSS + 10*EPSS + 10*GraphWeight - x0))142            * PRODUCT_c (1 - efficacy_c) for c in active controls143 144    This is exposed primarily for per-CVE reporting/auditing; asset-level145    annual frequency is calibrated by `calibrate_asset_annual_frequency`,146    which combines an asset's *worst* vulnerability with a bounded bonus for147    additional exposure rather than compounding every CVE multiplicatively148    (which would unrealistically saturate risk for any multi-CVE asset).149    """150    raw_score = _vulnerability_raw_score(cvss_score, epss_score, graph_weight)151    base_probability = float(_sigmoid(SIGMOID_K * (raw_score - SIGMOID_X0)))152 153    control_survival_factor = 1.0154    for efficacy in active_control_efficacies:155        control_survival_factor *= (1.0 - efficacy)156 157    return base_probability * control_survival_factor158 159 160def calibrate_asset_annual_frequency(161    asset: Asset,162    graph_exposure: AssetGraphExposure,163    active_controls: list[SecurityControl],164) -> float:165    """166    Calibrates the annual breach-event frequency (lambda) for an asset,167    suitable as a Poisson rate parameter.168 169    Rather than compounding every unpatched vulnerability's probability via170    a probabilistic OR (which saturates to near-certainty for any asset with171    more than a handful of CVEs), the combined exposure score is built from172    the single *worst* unpatched vulnerability plus a small additive bonus173    per additional unpatched vulnerability (representing incremental attack174    surface without runaway compounding). That score is passed through the175    sigmoid link and then scaled by a tier-specific ceiling on realistic176    annual breach frequency (`TIER_MAX_ANNUAL_FREQUENCY`), so even a177    maximally exploitable, maximally exposed asset is bounded to a plausible178    worst-case number of successful compromises per year.179 180    Active security controls reduce the exploit probability multiplicatively181    via their `likelihood_reduction` efficacy.182    """183    controls_for_asset = [184        c185        for c in active_controls186        if c.target_tier == "All" or c.target_tier == asset.tier187    ]188    control_survival_factor = 1.0189    for control in controls_for_asset:190        control_survival_factor *= (1.0 - control.likelihood_reduction)191 192    tier_max_frequency = TIER_MAX_ANNUAL_FREQUENCY.get(asset.tier, 1.5)193    unpatched_vulns = [v for v in asset.vulnerabilities if not v.is_patched]194 195    if not unpatched_vulns:196        exploit_probability = BASELINE_EXPLOIT_PROBABILITY197    else:198        raw_scores = [199            _vulnerability_raw_score(200                v.cvss_score, v.epss_score, graph_exposure.path_exposure_coefficient201            )202            for v in unpatched_vulns203        ]204        combined_score = max(raw_scores) + PER_EXTRA_VULN_SCORE_BONUS * (len(unpatched_vulns) - 1)205        exploit_probability = float(_sigmoid(SIGMOID_K * (combined_score - SIGMOID_X0)))206 207    return tier_max_frequency * exploit_probability * control_survival_factor208 209 210# --------------------------------------------------------------------------- #211# Financial impact modeling212# --------------------------------------------------------------------------- #213def _downtime_hours_params(tier: str) -> tuple[float, float, float]:214    """Beta-PERT (min, mode, max) downtime hours per incident, by asset tier."""215    return {216        "Critical": (1.0, 4.0, 24.0),217        "Medium": (0.5, 2.0, 12.0),218        "Low": (0.25, 1.0, 6.0),219    }.get(tier, (0.5, 2.0, 12.0))220 221 222def _breach_fraction_params(tier: str) -> tuple[float, float, float]:223    """224    Beta-PERT (min, mode, max) fraction of an asset's PII/financial records225    exposed in a *data-breach* incident (not every incident is a breach;226    see `_sample_composite_event_losses` for the breach-occurrence gate).227    """228    return {229        "Critical": (0.02, 0.10, 0.60),230        "Medium": (0.01, 0.05, 0.35),231        "Low": (0.005, 0.02, 0.15),232    }.get(tier, (0.01, 0.05, 0.35))233 234 235def _sample_composite_event_losses(236    asset: Asset,237    n_events: int,238    rng: np.random.Generator,239    asset_lineage: Optional[AssetLineageProfile] = None,240) -> np.ndarray:241    """242    Samples the per-event composite loss (₹) for `n_events` independent243    security incidents affecting `asset`, combining five cost components:244 245      1. Outage cost      = downtime_hours (Beta-PERT) x revenue_per_minute x 60246      2. Data breach cost = breach_fraction (Beta-PERT) x records x per-record cost,247                             gated by a per-incident breach-occurrence Bernoulli draw248                             (incorporates inherited records if asset is an unauthorized sink)249      3. Regulatory penalty:250           - DPDPA 2023 penalty, scaled by breach severity and record volume251           - RBI CSF / SEBI CSCRF fine, gated by regulated status and a252             reportable-downtime threshold253           - Cross-border statutory penalty254           - Downstream unauthorized sub-processor violation fine under DPDPA Section 8(4) & RBI Guidelines255      4. Forensics & incident response cost (Uniform ₹15L - ₹35L)256 257    Returns an array of shape (n_events,) of total composite losses in ₹.258    """259    if n_events == 0:260        return np.array([], dtype=float)261 262    size = (n_events,)263 264    # --- 1. Outage cost --------------------------------------------------- #265    dt_min, dt_mode, dt_max = _downtime_hours_params(asset.tier)266    downtime_hours = _pert_rvs(dt_min, dt_mode, dt_max, size, rng)267    outage_cost = downtime_hours * asset.revenue_per_minute * 60.0268 269    # --- 2. Data breach cost ------------------------------------------------#270    breach_occurs = rng.random(size) < {"Critical": 0.55, "Medium": 0.35, "Low": 0.20}.get(271        asset.tier, 0.35272    )273    bf_min, bf_mode, bf_max = _breach_fraction_params(asset.tier)274    breach_fraction = _pert_rvs(bf_min, bf_mode, bf_max, size, rng)275 276    records_pii = float(asset.pii_records_count)277    records_fin = float(asset.financial_records_count)278 279    # Downstream unauthorized leak liability calculation280    subprocessor_violation_fine = np.zeros(size, dtype=float)281    if asset_lineage is not None and getattr(asset_lineage, "is_unauthorized_sink", False):282        # Asset C exposes Origin A's records283        records_pii = float(asset.pii_records_count + getattr(asset_lineage, "inherited_pii_count", 0))284        records_fin = float(asset.financial_records_count + getattr(asset_lineage, "inherited_fin_count", 0))285 286        # Statutory third-party failure fine under DPDPA Section 8(4) & RBI Guidelines:287        # Dual penalty: Primary exfiltration penalty + failure of data principal consent oversight288        subprocessor_violation_fine = np.where(289            breach_occurs,290            rng.uniform(15_000_000.0, 40_000_000.0, size=size),  # ₹1.5 Cr to ₹4 Cr291            0.0,292        )293 294    records_exposed_pii = breach_fraction * records_pii295    records_exposed_fin = breach_fraction * records_fin296    data_breach_cost = np.where(297        breach_occurs,298        records_exposed_pii * PII_RECORD_COST_INR299        + records_exposed_fin * FINANCIAL_RECORD_COST_INR,300        0.0,301    )302 303    # --- 3a. DPDPA 2023 penalty -------------------------------------------- #304    total_records_breached = records_exposed_pii + records_exposed_fin305    severity_factor = np.clip(total_records_breached / 5_000_000.0, 0.0, 1.0)306    discretion_multiplier = np.clip(307        stats.lognorm.rvs(s=0.5, scale=0.06, size=size, random_state=rng), 0.0, 1.0308    )309    dpdpa_penalty = np.where(310        breach_occurs,311        DPDPA_MAX_PENALTY_INR * severity_factor * discretion_multiplier,312        0.0,313    )314 315    # --- 3b. RBI CSF / SEBI CSCRF fine -------------------------------------- #316    is_regulated = asset.is_rbi_regulated or asset.is_sebi_regulated317    reportable_incident = downtime_hours >= REPORTABLE_DOWNTIME_HOURS318    rbi_sebi_fine = np.where(319        is_regulated & reportable_incident,320        rng.uniform(RBI_SEBI_FINE_MIN_INR, RBI_SEBI_FINE_MAX_INR, size=size),321        0.0,322    )323 324    # --- 3c. Cross-border statutory penalty --------------------------------- #325    is_rbi_reg = getattr(asset, "is_rbi_regulated", False)326    is_rbi_loc = getattr(asset, "is_rbi_localization_compliant", True)327    cb_enabled = getattr(asset, "cross_border_transfer_enabled", False)328    legal_mech = getattr(asset, "transfer_legal_mechanism", "None") or "None"329 330    cross_border_violation = (331        (is_rbi_reg and not is_rbi_loc)332        or (cb_enabled and legal_mech in (None, "None"))333    )334    cross_border_fine = np.where(335        cross_border_violation & breach_occurs,336        rng.uniform(20_000_000.0, 50_000_000.0, size=size),  # ₹2 Cr to ₹5 Cr statutory enforcement fine337        0.0,338    )339 340    # --- 4. Forensics & incident response cost ------------------------------#341    forensics_cost = rng.uniform(FORENSICS_COST_MIN_INR, FORENSICS_COST_MAX_INR, size=size)342 343    total_loss = (344        outage_cost345        + data_breach_cost346        + dpdpa_penalty347        + rbi_sebi_fine348        + cross_border_fine349        + subprocessor_violation_fine350        + forensics_cost351    )352    return total_loss353 354 355# --------------------------------------------------------------------------- #356# Monte Carlo Simulation Engine357# --------------------------------------------------------------------------- #358@dataclass359class AssetSimulationResult:360    asset_id: int361    hostname: str362    tier: str363    asset_type: str364    annual_event_frequency: float365    eal_inr: float366    var_95_inr: float367    var_99_inr: float368    annual_loss_samples: np.ndarray = field(repr=False)369 370 371@dataclass372class EnterpriseSimulationResult:373    total_eal_inr: float374    var_95_inr: float375    var_99_inr: float376    per_asset_results: list[AssetSimulationResult]377    aggregate_loss_samples: np.ndarray = field(repr=False)378    total_regulatory_fine_exposure_inr: float379    iterations: int = N_ITERATIONS380    unauthorized_subprocessor_count: int = 0381    shadow_leakage_exposure_inr: float = 0.0382    identified_leak_vectors: list[Any] = field(default_factory=list)383 384 385class MonteCarloRiskEngine:386    """387    Runs a vectorized compound-Poisson Monte Carlo simulation across all388    assets to quantify Expected Annual Loss (EAL) and Value at Risk (VaR).389 390    Vectorization strategy: for each asset, `n_iterations` Poisson draws391    give the number of loss *events* in each simulated year. Rather than392    looping event-by-event, we draw a (n_iterations x max_events) matrix of393    candidate per-event severities and mask out entries beyond each394    iteration's actual event count, then sum across events per iteration.395    This keeps the simulation fully vectorized in NumPy even though the396    event count varies stochastically per iteration.397    """398 399    def __init__(400        self,401        assets: list[Asset],402        active_controls: list[SecurityControl] | None = None,403        n_iterations: int = N_ITERATIONS,404        seed: int = RNG_SEED,405        session: Session | None = None,406    ) -> None:407        self.assets = assets408        self.active_controls = active_controls or []409        self.n_iterations = n_iterations410        self.rng = np.random.default_rng(seed)411        self.session = session412        self.graph_engine = AttackGraphEngine(413            assets=assets,414            active_controls=self.active_controls,415            session=self.session,416        )417        self.exposure_map = self.graph_engine.compute_exposure()418        self.lineage_flows, self.lineage_profiles = self.graph_engine.trace_transitive_data_lineage()419 420    def _simulate_asset(self, asset: Asset) -> AssetSimulationResult:421        exposure = self.exposure_map[asset.id]422        lam = calibrate_asset_annual_frequency(asset, exposure, self.active_controls)423 424        event_counts = self.rng.poisson(lam=lam, size=self.n_iterations)425        max_events = int(event_counts.max()) if event_counts.size else 0426 427        if max_events == 0:428            annual_losses = np.zeros(self.n_iterations, dtype=float)429        else:430            lineage_prof = self.lineage_profiles.get(asset.id)431            candidate_losses = _sample_composite_event_losses(432                asset, self.n_iterations * max_events, self.rng, asset_lineage=lineage_prof433            ).reshape(self.n_iterations, max_events)434            event_slot_mask = np.arange(max_events)[None, :] < event_counts[:, None]435            annual_losses = (candidate_losses * event_slot_mask).sum(axis=1)436 437        eal = float(np.mean(annual_losses))438        var_95 = float(np.percentile(annual_losses, 95))439        var_99 = float(np.percentile(annual_losses, 99))440 441        return AssetSimulationResult(442            asset_id=asset.id,443            hostname=asset.hostname,444            tier=asset.tier,445            asset_type=asset.asset_type,446            annual_event_frequency=round(lam, 4),447            eal_inr=eal,448            var_95_inr=var_95,449            var_99_inr=var_99,450            annual_loss_samples=annual_losses,451        )452 453    def run(self) -> EnterpriseSimulationResult:454        """Executes the full enterprise Monte Carlo simulation."""455        per_asset_results = [self._simulate_asset(asset) for asset in self.assets]456 457        if per_asset_results:458            aggregate_losses = np.sum(459                [r.annual_loss_samples for r in per_asset_results], axis=0460            )461        else:462            aggregate_losses = np.zeros(self.n_iterations, dtype=float)463 464        total_eal = float(np.mean(aggregate_losses))465        var_95 = float(np.percentile(aggregate_losses, 95))466        var_99 = float(np.percentile(aggregate_losses, 99))467 468        # Regulatory-fine-only exposure estimate (DPDPA + RBI/SEBI + Cross-Border + Sub-Processor),469        # reported as the 95th percentile of a dedicated re-simulation of the470        # penalty components alone -- useful for the compliance dashboard.471        total_regulatory_exposure = self._estimate_regulatory_exposure()472 473        unauthorized_vectors = [474            f for f in self.lineage_flows475            if (not f.is_authorized or not f.has_user_consent or "Unauthorized" in f.detection_status)476        ]477        unauthorized_count = len(unauthorized_vectors)478 479        shadow_leakage_exposure = 0.0480        for r in per_asset_results:481            prof = self.lineage_profiles.get(r.asset_id)482            if prof and prof.is_unauthorized_sink:483                shadow_leakage_exposure += r.eal_inr484 485        return EnterpriseSimulationResult(486            total_eal_inr=total_eal,487            var_95_inr=var_95,488            var_99_inr=var_99,489            per_asset_results=per_asset_results,490            aggregate_loss_samples=aggregate_losses,491            total_regulatory_fine_exposure_inr=total_regulatory_exposure,492            iterations=self.n_iterations,493            unauthorized_subprocessor_count=unauthorized_count,494            shadow_leakage_exposure_inr=shadow_leakage_exposure,495            identified_leak_vectors=self.lineage_flows,496        )497 498    def _estimate_regulatory_exposure(self) -> float:499        """500        Isolates the regulatory-penalty share of the 95th-percentile annual501        loss across all assets, by re-deriving DPDPA + RBI/SEBI penalty502        samples with the same seeded RNG stream logic used in the main run.503        A fresh, independently seeded generator is used here so this504        estimate does not perturb the primary EAL/VaR simulation stream.505        """506        local_rng = np.random.default_rng(RNG_SEED + 1)507        penalty_totals = np.zeros(self.n_iterations, dtype=float)508 509        for asset in self.assets:510            exposure = self.exposure_map[asset.id]511            lam = calibrate_asset_annual_frequency(asset, exposure, self.active_controls)512            event_counts = local_rng.poisson(lam=lam, size=self.n_iterations)513            max_events = int(event_counts.max()) if event_counts.size else 0514            if max_events == 0:515                continue516 517            n = self.n_iterations * max_events518            size = (n,)519            breach_occurs = local_rng.random(size) < {520                "Critical": 0.55,521                "Medium": 0.35,522                "Low": 0.20,523            }.get(asset.tier, 0.35)524            bf_min, bf_mode, bf_max = _breach_fraction_params(asset.tier)525            breach_fraction = _pert_rvs(bf_min, bf_mode, bf_max, size, local_rng)526 527            records_pii = float(asset.pii_records_count)528            records_fin = float(asset.financial_records_count)529            lineage_prof = self.lineage_profiles.get(asset.id)530            subprocessor_fine = np.zeros(size, dtype=float)531            if lineage_prof is not None and getattr(lineage_prof, "is_unauthorized_sink", False):532                records_pii = float(asset.pii_records_count + getattr(lineage_prof, "inherited_pii_count", 0))533                records_fin = float(asset.financial_records_count + getattr(lineage_prof, "inherited_fin_count", 0))534                subprocessor_fine = np.where(535                    breach_occurs,536                    local_rng.uniform(15_000_000.0, 40_000_000.0, size=size),537                    0.0,538                )539 540            records_breached = breach_fraction * (records_pii + records_fin)541            severity_factor = np.clip(records_breached / 5_000_000.0, 0.0, 1.0)542            discretion_multiplier = np.clip(543                stats.lognorm.rvs(s=0.5, scale=0.06, size=size, random_state=local_rng), 0.0, 1.0544            )545            dpdpa = np.where(546                breach_occurs, DPDPA_MAX_PENALTY_INR * severity_factor * discretion_multiplier, 0.0547            )548 549            downtime_hours = _pert_rvs(*_downtime_hours_params(asset.tier), size, local_rng)550            is_regulated = asset.is_rbi_regulated or asset.is_sebi_regulated551            reportable = downtime_hours >= REPORTABLE_DOWNTIME_HOURS552            rbi_sebi = np.where(553                is_regulated & reportable,554                local_rng.uniform(RBI_SEBI_FINE_MIN_INR, RBI_SEBI_FINE_MAX_INR, size=size),555                0.0,556            )557 558            is_rbi_reg = getattr(asset, "is_rbi_regulated", False)559            is_rbi_loc = getattr(asset, "is_rbi_localization_compliant", True)560            cb_enabled = getattr(asset, "cross_border_transfer_enabled", False)561            legal_mech = getattr(asset, "transfer_legal_mechanism", "None") or "None"562 563            cross_border_violation = (564                (is_rbi_reg and not is_rbi_loc)565                or (cb_enabled and legal_mech in (None, "None"))566            )567            cross_border_fine = np.where(568                cross_border_violation & breach_occurs,569                local_rng.uniform(20_000_000.0, 50_000_000.0, size=size),570                0.0,571            )572 573            per_event_penalty = (dpdpa + rbi_sebi + cross_border_fine + subprocessor_fine).reshape(574                self.n_iterations, max_events575            )576            event_slot_mask = np.arange(max_events)[None, :] < event_counts[:, None]577            penalty_totals += (per_event_penalty * event_slot_mask).sum(axis=1)578 579        return float(np.percentile(penalty_totals, 95)) if self.assets else 0.0580 581    def loss_exceedance_curve(582        self, aggregate_losses: np.ndarray583    ) -> list[tuple[int, float]]:584        """585        Computes Loss Exceedance Curve points for standard return periods.586        A "1-in-T-year loss" is the loss level exceeded with probability587        1/T, i.e. the (1 - 1/T) quantile of the annual aggregate loss588        distribution.589        """590        points: list[tuple[int, float]] = []591        for t in LOSS_EXCEEDANCE_RETURN_PERIODS:592            quantile = 1.0 - (1.0 / t)593            loss_at_quantile = float(np.percentile(aggregate_losses, quantile * 100.0))594            points.append((t, loss_at_quantile))595        return points596 597 598def run_enterprise_simulation(599    db: Session,600    assets: list[Asset],601    active_controls: list[SecurityControl] | None = None,602    persist: bool = True,603    allocated_budget_inr: float | None = None,604) -> EnterpriseSimulationResult:605    """606    Convenience entry point: runs the full Monte Carlo simulation and607    optionally persists the aggregate result to the `SimulationRun` table.608    """609    engine = MonteCarloRiskEngine(assets=assets, active_controls=active_controls, session=db)610    result = engine.run()611 612    if persist:613        selected_codes = [c.code for c in (active_controls or [])]614        run_record = SimulationRun(615            total_eal_inr=result.total_eal_inr,616            var_95_inr=result.var_95_inr,617            var_99_inr=result.var_99_inr,618            allocated_budget_inr=allocated_budget_inr,619            selected_controls_json=json.dumps(selected_codes),620        )621        db.add(run_record)622        db.commit()623        db.refresh(run_record)624 625    return result626 627