diff --git a/pyproject.toml b/pyproject.toml index 3dff8ba..0fb2545 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,10 @@ llm = [ ccw = [ "pydantic>=2.0.0", ] +lead = [ + "stack[conf]", + "narwhals>=2.17.0", +] cli = [ "stack[aco]", "stack[api]", @@ -180,6 +184,7 @@ all = [ "stack[rex]", "stack[lake]", "stack[nlm]", + "stack[lead]", ] [dependency-groups] @@ -247,6 +252,6 @@ link-mode = "copy" python-preference = "managed" [tool.uv.build-backend] -module-name = ["aco", "api", "bcda", "bib", "bls", "ccw", "cli", "cms", "conf", "llm", "mail", "nlm", "opps", "perf", "pfs", "prisma", "qpp", "rec", "rex", "sem"] +module-name = ["aco", "api", "bcda", "bib", "bls", "ccw", "cli", "cms", "conf", "lead", "llm", "mail", "nlm", "opps", "perf", "pfs", "prisma", "qpp", "rec", "rex", "sem"] namespace = true source-exclude = ["compose.yml","infra/**","data/**","notebooks/**","tuva/**","assets/**","docs/**","dev/**","bundle/**","cloud/**"] diff --git a/src/lead/__init__.py b/src/lead/__init__.py new file mode 100644 index 0000000..8d49ade --- /dev/null +++ b/src/lead/__init__.py @@ -0,0 +1,37 @@ +"""LEAD Model PY 2027 financial methodology — CMS's published rules. + +The Long-term Enhanced ACO Design (LEAD) Model is CMS Innovation Center's +ACO model starting in PY 2027. This package ports the arithmetic of its +public methodology paper — "LEAD Model PY 2027 Alignment and Financial +Methodology Paper" v1.2, August 27, 2026 (cms.gov/priorities/innovation/ +files/lead-alignment-finance-paper.pdf; bib item ``E4PEU3HV``) — as +generic engines. Parameters come from the paper (:mod:`lead.params`); +everything CMS computes per ACO or per year comes from the caller. It holds +no participant data and no organization-specific assumptions. + +Modules: + +- :mod:`lead.params` — Table 5.2.1 discounts, Quality Withhold, + retention incentive, Stop-Loss bands, risk corridors, sequestration, + base-year weights, ACPT guardrails, risk-score caps, REA/PSA shares and + caps, voluntary guardrail and minimum, Table 2.6.1 base-year minimums. +- :mod:`lead.settlement` — Final Financial Settlement, Table 5.6.1 line by + line (:func:`lead.settlement.settle`). +- :mod:`lead.stop_loss` — Stop-Loss Reinsurance (Box 5.3.3). +- :mod:`lead.monies_owed` — Total Monies Owed (Table 5.5.1). +- :mod:`lead.timing` — run-out, settlement basis, settlement-stage inputs. +- :mod:`lead.benchmark` — §3 benchmark formulas. +- :mod:`lead.express` — the same as narwhals expressions, so a frame of + scenarios settles at once on polars, DuckDB, or any narwhals backend. + +Conventions: + +- Dollar lines are rounded to the cent as they are produced + (:func:`lead.money.cents`, half away from zero); PBPMs, rates and + factors never are. That is the only rule under which Tables 3.3.3, + 3.5.3.2, 3.5.4, 5.5.1 and 5.6.1 all reproduce to the cent. +- A missing input stays missing (None/null, or a refused input model), + never a silent zero; the zeros that appear are ones the paper defines. +- Docstrings cite "LEAD PY2027 methodology (bib E4PEU3HV) §x.y / Table + x.y.z". +""" diff --git a/src/lead/benchmark.py b/src/lead/benchmark.py new file mode 100644 index 0000000..f6d4c4d --- /dev/null +++ b/src/lead/benchmark.py @@ -0,0 +1,652 @@ +"""LEAD Performance Year Benchmark formulas (§3), as published. + +The order: Historical Baseline Expenditure PBPMs are risk standardized +(§3.3.1), trended to BY 3 with the national-regional blend (§3.3.2) and +weighted by base year (§3.3.3–3.3.4) into the Historical Benchmark; the +voluntary guardrail applies (§3.2.5); a Regional Efficiency Adjustment or +Prior Savings Adjustment is added (§3.4); the Adjusted Historical +Benchmark is trended with the three-way blend (§3.5.1), risk adjusted with +the growth-capped PY risk score (§3.5.2), multiplied out over full-year and +hybrid-aligned months (§3.5.3), and the categories summed (§3.5.4). + +Only the arithmetic CMS publishes is here. Every data-derived input — +expenditures, risk scores, months, ACPT, modified USPCC amounts, hybrid +factors, CMS's alignment-standard and SSP-TIN determinations — is a caller +input. PBPMs, rates and factors are never rounded; dollar totals are +rounded to the cent (:func:`lead.money.cents`), which is what reproduces +Table 3.5.4 line 24. + +Missing inputs give ``None``, never a stand-in value: a growth rate, +market share or PBPM without a positive denominator is None, and so is +anything computed from it. + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §3. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass + +from lead.money import cents +from lead.params import ( + ACPT_GUARDRAILS, + ACPT_WEIGHT, + BASE_YEAR_ALIGNMENT_MINIMUMS, + BASE_YEAR_WEIGHTS, + BASE_YEARS, + BENCHMARK_ADJUSTMENT_CAP_RATE, + GLOBAL, + HIGHER_SPENDING, + LOWER_SPENDING, + NEWLY_ENTERING, + PRIOR_SAVINGS_PRORATION_CAP, + PRIOR_SAVINGS_SHARE, + REDUCED_BENCHMARK_ADJUSTMENT_CAP_RATE, + REGIONAL_EFFICIENCY_SHARE, + RISK_SCORE_GROWTH_CAPS, + VOLUNTARY_BENCHMARK_MINIMUM, + VOLUNTARY_GUARDRAIL_RATE, + AlignmentMinimumType, + BeneficiaryCategory, + EntryType, + RiskOption, + SpendingDesignation, + model_year, +) + +# ── Shared arithmetic ───────────────────────────────────────────────────── + + +def ratio(numerator: float | None, denominator: float | None) -> float | None: + """Return numerator ÷ denominator, or None unless both are known and + the denominator is positive. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3 (every ratio it + defines has a positive denominator). + """ + if numerator is None or denominator is None or denominator <= 0: + return None + return numerator / denominator + + +def month_weighted_average( + values_and_months: Iterable[tuple[float | None, float | None]], +) -> float | None: + """Return the beneficiary month-weighted average of the values. + + Pairs with a missing value or no positive months carry no weight; with + no weight at all the result is None. This is the paper's average risk + score, its claims/voluntary PBPM combination, its weighted REA for + excluded TINs and Box 5.3.3's month-weighted Stop-Loss values. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.3.1 / §3.5.2 / + §3.5.4 / Table 3.4.3.6 / Box 5.3.3. + """ + weighted = 0.0 + months = 0.0 + for value, weight in values_and_months: + if value is None or weight is None or weight <= 0: + continue + weighted += value * weight + months += weight + return ratio(weighted, months) + + +# ── §3.3 Risk standardize, trend, weight ────────────────────────────────── + + +def expenditure_pbpm(total_expenditure: float, eligible_months: float) -> float | None: + """Return expenditure per beneficiary per month (Table 3.3.3 line 3). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.3 / Table 3.3.3 line 3. + """ + return ratio(total_expenditure, eligible_months) + + +def risk_standardize( + pbpm: float | None, average_risk_score: float | None +) -> float | None: + """Divide a PBPM by the same population's average risk score for the + same year (Table 3.3.3 line 5). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.3.1 / Table 3.3.3 line 5. + """ + return ratio(pbpm, average_risk_score) + + +def growth_rate(later: float | None, earlier: float | None) -> float | None: + """Return a growth rate later ÷ earlier: BY 3 ÷ BY n FFS expenditures, + national (Step 1) or regional (Step 2). None unless both are positive. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Box 3.3.2 Steps 1–2. + """ + if later is None or later <= 0: + return None + return ratio(later, earlier) + + +def national_trend_weight( + counties: Sequence[tuple[float, float]], +) -> float | None: + """Return the weight on the national growth rate: the ACO's + county-weighted average market share. + + Each county is (ACO-aligned months, alignment-eligible months). Market + share = aligned ÷ eligible; county weight = aligned ÷ ACO total aligned; + the national weight is Σ share × weight. None without aligned months or + when a county has no eligible months. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Box 3.3.2 Step 3. + """ + total_aligned = sum(aligned for aligned, _ in counties) + weight_sum = 0.0 + for aligned, eligible in counties: + share = ratio(aligned, eligible) + weight = ratio(aligned, total_aligned) + if share is None or weight is None: + return None + weight_sum += share * weight + return weight_sum if counties else None + + +def two_way_blended_trend( + national_growth: float | None, + regional_growth: float | None, + national_weight: float | None, +) -> float | None: + """Blend the growth rates: national × weight + regional × (1 − weight). + + Box 3.3.2's example gives 1.120 × 18% + 1.100 × 82% = 1.1036 (printed + 1.104; its next sentence says 1.103, a slip in the paper). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Box 3.3.2 Step 4. + """ + if national_growth is None or regional_growth is None or national_weight is None: + return None + return national_growth * national_weight + regional_growth * (1.0 - national_weight) + + +def base_year_weights( + entry_type: EntryType, sufficient_base_years: Iterable[str] +) -> dict[str, float]: + """Return the weight of each sufficient base year. + + Three sufficient years: 10/30/60 (Newly Entering) or equal thirds + (Renewing). Two: two-thirds recent / one-third older (Newly Entering) + or equal halves (Renewing). One: 100 percent. None sufficient: an empty + mapping — no Historical Benchmark can be built. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.3.3 / §3.3.4. + """ + if entry_type not in BASE_YEAR_WEIGHTS: + raise ValueError(f"unknown entry type {entry_type!r}") + sufficient = set(sufficient_base_years) + unknown = sufficient - set(BASE_YEARS) + if unknown: + raise ValueError(f"unknown base years {sorted(unknown)}") + years = [year for year in BASE_YEARS if year in sufficient] + if len(years) == 3: + return dict(BASE_YEAR_WEIGHTS[entry_type]) + if len(years) == 2: + older, recent = years + if entry_type == NEWLY_ENTERING: + return {older: 1 / 3, recent: 2 / 3} + return {older: 0.5, recent: 0.5} + return {year: 1.0 for year in years} + + +def base_year_alignment_minimum( + standard: AlignmentMinimumType, performance_year: int +) -> int: + """Return the claims-based alignment minimum a base year must meet, for + the alignment standard CMS assigned the ACO and the PY. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §2.6 / Table 2.6.1. + """ + return BASE_YEAR_ALIGNMENT_MINIMUMS[standard][model_year(performance_year) - 1] + + +def base_year_is_sufficient( + claims_aligned_beneficiaries: int, + standard: AlignmentMinimumType, + performance_year: int, +) -> bool: + """Whether a base year has sufficient claims history: ACO-level + claims-based alignment meets or exceeds the Table 2.6.1 minimum. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.3.4. + """ + return claims_aligned_beneficiaries >= base_year_alignment_minimum( + standard, performance_year + ) + + +def historical_benchmark( + risk_standardized_pbpm: Mapping[str, float], + trend_to_by3: Mapping[str, float], + weights: Mapping[str, float], +) -> float | None: + """Return the Historical Benchmark PBPM: Σ risk-standardized PBPM × + trend to BY 3 × base-year weight over the weighted base years. + + BY 3's trend factor is 1.000. None when a weighted year lacks its PBPM + or trend, or when no year is weighted. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.3.3 / Table 3.3.3 + lines 5–9. + """ + if not weights: + return None + total = 0.0 + for year, weight in weights.items(): + pbpm = risk_standardized_pbpm.get(year) + trend = trend_to_by3.get(year) + if pbpm is None or trend is None: + return None + total += pbpm * trend * weight + return total + + +# ── §3.2.5 Voluntarily aligned benchmarks ───────────────────────────────── + +WITHIN_GUARDRAIL = "within_voluntary_guardrail" +CAPPED_AT_UPPER = "capped_at_110_percent_of_claims_aligned" +RAISED_TO_LOWER = "raised_to_90_percent_of_claims_aligned" + + +def voluntary_guardrail( + voluntary_historical_pbpm: float, + claims_historical_pbpm: float, + rate: float = VOLUNTARY_GUARDRAIL_RATE, +) -> tuple[float, str]: + """Hold the voluntarily aligned Historical Benchmark within ±10 percent + of the claims-aligned one for the same category, after base-year + blending and before any REA or PSA. Returns (benchmark, status). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.2.5 ($1,000 claims → + $900–$1,100). + """ + lower = claims_historical_pbpm * (1.0 - rate) + upper = claims_historical_pbpm * (1.0 + rate) + if voluntary_historical_pbpm > upper: + return upper, CAPPED_AT_UPPER + if voluntary_historical_pbpm < lower: + return lower, RAISED_TO_LOWER + return voluntary_historical_pbpm, WITHIN_GUARDRAIL + + +def contributes_to_voluntary_benchmark( + category: BeneficiaryCategory, by3_category: BeneficiaryCategory | None +) -> bool: + """Whether a voluntarily aligned beneficiary contributes base-year + expenditures (and counts toward the 500 minimum): every A&D and ESRD + beneficiary, and a High Needs one only if High Needs in BY 3. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.2.5. + """ + return category != "High Needs" or by3_category == "High Needs" + + +def voluntary_benchmark_minimum_met(contributing_beneficiaries: int) -> bool: + """Whether at least 500 contributing voluntarily aligned beneficiaries + exist; otherwise the claims-aligned benchmarks apply to them. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.2.5. + """ + return contributing_beneficiaries >= VOLUNTARY_BENCHMARK_MINIMUM + + +# ── §3.4 ACO-specific benchmark adjustments ─────────────────────────────── + + +def regional_ffs_expenditure( + counties: Sequence[tuple[float | None, float | None, float]], +) -> float | None: + """Return regional FFS expenditure: Σ county weight × risk-standardized + county FFS PBPM. + + Each county is (county PBPM FFS expenditure, county average risk score, + ACO-aligned months in the county); the weight is the county's share of + the ACO's aligned months. None if any county lacks a PBPM or score, or + the ACO has no aligned months. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.4.1. + """ + standardized = [risk_standardize(pbpm, risk) for pbpm, risk, _ in counties] + if None in standardized: + return None + return month_weighted_average( + (value, months) + for value, (_, _, months) in zip(standardized, counties, strict=True) + ) + + +@dataclass(frozen=True) +class SpendingDesignationResult: + """The §3.4.2 ACO-level spending difference and the designation.""" + + weighted_difference: float + designation: SpendingDesignation | None + + +def spending_designation( + categories: Sequence[tuple[float, float, float]], +) -> SpendingDesignationResult: + """Designate the ACO higher or lower spending. + + Each category is (BY 3 regional FFS PBPM, Historical Benchmark PBPM, + BY 3 aligned months). The ACO-level difference is Σ (regional − + historical) × the category's share of BY 3 aligned months: positive is + lower spending, negative higher. The paper names no tie, so an exact + zero is left undetermined (``designation=None``). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.4.2 / Table 3.4.2. + """ + total_months = sum(months for _, _, months in categories) + if total_months <= 0: + raise ValueError("the spending designation needs BY 3 aligned months") + difference = sum( + (regional - historical) * months / total_months + for regional, historical, months in categories + ) + designation: SpendingDesignation | None = ( + LOWER_SPENDING + if difference > 0 + else HIGHER_SPENDING + if difference < 0 + else None + ) + return SpendingDesignationResult(difference, designation) + + +def benchmark_adjustment_cap_rate(reduced: bool) -> float: + """Return the benchmark adjustment cap rate: 5 percent, or 3 percent + when CMS finds a lower-spending ACO has more than 40 percent of its + Participant TINs from a Shared Savings Program ACO (``reduced``). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.4 / §3.4.3. + """ + return ( + REDUCED_BENCHMARK_ADJUSTMENT_CAP_RATE + if reduced + else BENCHMARK_ADJUSTMENT_CAP_RATE + ) + + +def benchmark_adjustment_cap( + uspcc_amount: float, py_average_risk_score: float, cap_rate: float +) -> float: + """Return the risk-adjusted cap: modified-USPCC amount × PY average risk + score × cap rate ($3,500 × 1.030 × 5% = $180.25). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.4.3 / Table 3.4.3.3. + """ + return uspcc_amount * py_average_risk_score * cap_rate + + +def regional_efficiency_eligible( + spending: SpendingDesignation, risk_option: RiskOption +) -> bool: + """Whether the Regional Efficiency Adjustment applies: lower-spending + ACOs in the Global Risk track only. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.4.3. + """ + return spending == LOWER_SPENDING and risk_option == GLOBAL + + +@dataclass(frozen=True) +class RegionalEfficiencyAdjustment: + """Tables 3.4.3.1 and 3.4.3.3 for one category and alignment type.""" + + difference: float + uncapped: float + cap: float | None + applied: float | None + + +def regional_efficiency_adjustment( + regional_ffs_pbpm: float, + historical_benchmark_pbpm: float, + *, + eligible: bool, + uspcc_amount: float | None, + py_average_risk_score: float | None, + cap_rate: float, +) -> RegionalEfficiencyAdjustment: + """Return the REA for one category and alignment type. + + Uncapped = 50 percent of the positive regional − historical difference; + applied = the lesser of it and the cap. An ineligible ACO, or one at or + above regional spending, gets zero. A positive uncapped REA whose cap + inputs are missing is None — never the uncapped amount. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.4.3 / Tables + 3.4.3.1–3.4.3.4. + """ + difference = regional_ffs_pbpm - historical_benchmark_pbpm + uncapped = max(0.0, difference) * REGIONAL_EFFICIENCY_SHARE + cap = ( + None + if uspcc_amount is None or py_average_risk_score is None + else benchmark_adjustment_cap(uspcc_amount, py_average_risk_score, cap_rate) + ) + if not eligible or uncapped == 0.0: + applied: float | None = 0.0 + else: + applied = None if cap is None else min(uncapped, cap) + return RegionalEfficiencyAdjustment(difference, uncapped, cap, applied) + + +def prior_savings_pbpm( + py_benchmark: float, py_expenditure: float, aligned_months: float +) -> float | None: + """Return a prior year's gross savings (losses) PBPM: (benchmark − + expenditure) ÷ aligned beneficiary months. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.4.4 / Table 3.4.3.7. + """ + return ratio(py_benchmark - py_expenditure, aligned_months) + + +@dataclass(frozen=True) +class PriorSavingsAdjustment: + """§3.4.4 Examples 1–3.""" + + average_savings_pbpm: float + eligible: bool + proration_factor: float + prorated_average_savings: float + uncapped: float + cap: float | None + applied: float | None + + +def prior_savings_adjustment( + average_savings_pbpm: float, + predecessor_months: float, + py_months: float, + cap: float | None, +) -> PriorSavingsAdjustment: + """Return the Prior Savings Adjustment PBPM. + + Eligible only when the average PBPM savings is positive. Proration = + predecessor average months ÷ PY months, capped at 100 percent; + uncapped = 50 percent of the prorated average; applied = the lesser of + it and the ACO-level cap (None when an eligible ACO's cap is missing). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.4.4 (Examples 1–3). + """ + if py_months <= 0: + raise ValueError("the proration factor needs PY beneficiary months") + eligible = average_savings_pbpm > 0 + proration = min(predecessor_months / py_months, PRIOR_SAVINGS_PRORATION_CAP) + prorated = average_savings_pbpm * proration + uncapped = prorated * PRIOR_SAVINGS_SHARE if eligible else 0.0 + if not eligible: + applied: float | None = 0.0 + else: + applied = None if cap is None else min(uncapped, cap) + return PriorSavingsAdjustment( + average_savings_pbpm, eligible, proration, prorated, uncapped, cap, applied + ) + + +def prior_savings_cap( + subpopulations: Sequence[tuple[float, float, float]], cap_rate: float +) -> float: + """Return the ACO-level Prior Savings Adjustment cap: Σ sub-population + cap × its share of PY alignment, each sub-population being (share, + USPCC-based amount, PY average risk score). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.4.4 / Table 3.4.3.9. + """ + return sum( + share * benchmark_adjustment_cap(uspcc, risk, cap_rate) + for share, uspcc, risk in subpopulations + ) + + +# ── §3.5 Update to the Performance Year ─────────────────────────────────── + + +@dataclass(frozen=True) +class ThreeWayTrend: + """§3.5.1: the raw three-way factor, its guardrail band and the factor + applied.""" + + raw: float + lower_bound: float + upper_bound: float + applied: float + + +def three_way_blended_trend( + two_way_trend: float, acpt: float, performance_year: int +) -> ThreeWayTrend: + """Blend two-thirds two-way trend and one-third ACPT, then hold the + result within the PY's guardrail around the two-way trend. + + Both factors are cumulative from BY 3 to the PY. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.5.1 / Table 3.5.1. + """ + raw = (1.0 - ACPT_WEIGHT) * two_way_trend + ACPT_WEIGHT * acpt + low, high = ACPT_GUARDRAILS[model_year(performance_year)] + lower, upper = two_way_trend + low, two_way_trend + high + return ThreeWayTrend(raw, lower, upper, min(max(raw, lower), upper)) + + +def cumulative_acpt(annual_rates: Sequence[float | None]) -> float | None: + """Return the cumulative BY 3 → PY ACPT factor: Π (1 + each year's + ACPT). Each year keeps its original prospective value even when a + guardrail bound an earlier year. One missing year leaves it None. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.5.1 (ACPT Guardrail + Policy). + """ + if not annual_rates: + return None + factor = 1.0 + for rate in annual_rates: + if rate is None: + return None + factor *= 1.0 + rate + return factor + + +WITHIN_CAP = "within_risk_score_growth_cap" +CAPPED = "capped_at_risk_score_growth_cap" +FLOORED = "raised_to_risk_score_growth_floor" + + +def capped_risk_score( + by3_risk_score: float, py_risk_score: float, category: BeneficiaryCategory +) -> tuple[float, str]: + """Hold the PY average risk score within BY 3 × (1 ± cap): 3 percent + for A&D and ESRD, 4 percent for High Needs. Returns (score, status). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.5.2 (Risk Score + Growth Cap). + """ + cap = RISK_SCORE_GROWTH_CAPS[category] + upper = by3_risk_score * (1.0 + cap) + lower = by3_risk_score * (1.0 - cap) + if py_risk_score > upper: + return upper, CAPPED + if py_risk_score < lower: + return lower, FLOORED + return py_risk_score, WITHIN_CAP + + +def performance_year_benchmark_pbpm( + historical_benchmark_pbpm: float, + benchmark_adjustment: float | None, + trend_factor: float, + capped_py_risk_score: float, +) -> float | None: + """Return the PY Benchmark PBPM: (Historical Benchmark + REA or PSA) × + three-way trend × capped PY risk score ($1,100 × 1.030 = $1,133). + + A missing adjustment (None, e.g. an uncomputable cap) stays missing. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.5 / §3.5.2. + """ + if benchmark_adjustment is None: + return None + adjusted = historical_benchmark_pbpm + benchmark_adjustment + return adjusted * trend_factor * capped_py_risk_score + + +def hybrid_adjustment_factor( + partial_period_pbpm: float, full_year_pbpm: float +) -> float | None: + """Return a hybrid alignment adjustment factor: average PBPM over the + partial-year period ÷ average full-year PBPM. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.5.3 / Table 3.5.3.1. + """ + return ratio(partial_period_pbpm, full_year_pbpm) + + +@dataclass(frozen=True) +class BenchmarkDollars: + """Table 3.5.3.2 lines 6–8 for one category and alignment type.""" + + full_year: float + hybrid: float + total: float + + +def benchmark_dollars( + py_benchmark_pbpm: float, + full_year_months: float, + hybrid_months_and_factors: Sequence[tuple[float, float]] = (), +) -> BenchmarkDollars: + """Multiply a PY Benchmark PBPM out to dollars over full-year months + and hybrid-aligned months at their adjustment factors. + + ``total`` = cents(PBPM × [full months + Σ hybrid months × factor]), one + product as Table 3.5.4 lines 8 and 22 state it; ``full_year`` and + ``hybrid`` are the Table 3.5.3.2 lines 6 and 7 components. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.5.3 / Table 3.5.3.2 / + Table 3.5.4 lines 8 and 22. + """ + hybrid_months = sum(months * factor for months, factor in hybrid_months_and_factors) + return BenchmarkDollars( + full_year=cents(py_benchmark_pbpm * full_year_months), + hybrid=cents(py_benchmark_pbpm * hybrid_months), + total=cents(py_benchmark_pbpm * (full_year_months + hybrid_months)), + ) + + +def total_benchmark(category_dollars: Iterable[float]) -> float: + """Sum aggregate benchmark dollars (claims + voluntary per category, + then A&D + ESRD + High Needs) into the PY Benchmark. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.5.4 / Table 3.5.4 + line 24. + """ + return cents(sum(category_dollars)) diff --git a/src/lead/express/__init__.py b/src/lead/express/__init__.py new file mode 100644 index 0000000..b98e0cb --- /dev/null +++ b/src/lead/express/__init__.py @@ -0,0 +1,62 @@ +"""narwhals ``Expr`` builders for the LEAD financial methodology. + +Each builder takes column names or ``nw.Expr`` objects and returns an +``nw.Expr``, so the same methodology runs on polars, DuckDB, pandas, or any +other narwhals backend, one row per scenario, beneficiary, or benchmark +cell. The scalar twins live in ``lead.settlement``, ``lead.stop_loss``, +``lead.benchmark``; both sides perform the same float operations, so a +frame and a loop over the scalars agree to the bit. + +Null in, null out: a missing input produces a null line, never a silent +zero. The zeros these builders do produce are the paper's own, as in the +scalars. +""" + +from __future__ import annotations + +import narwhals as nw + +IntoExpr = str | nw.Expr +"""A column name or a narwhals expression.""" + + +def col(value: IntoExpr) -> nw.Expr: + """Return ``value`` as an expression (a string names a column).""" + return nw.col(value) if isinstance(value, str) else value + + +def lit_or_col(value: IntoExpr | float | int) -> nw.Expr: + """Return a literal for a number, otherwise :func:`col`.""" + return nw.lit(value) if isinstance(value, int | float) else col(value) + + +def cents(value: IntoExpr) -> nw.Expr: + """Round a dollar expression to the cent, half away from zero. + + The rounding is :func:`lead.money.cents`'s: ``floor(|x| × 100 + 0.5)``, + not the backends' ``round``, which differ on ties (polars half to even, + DuckDB away from zero). The final ``÷ 100`` is snapped with + ``round(2)`` because polars divides a column by a literal as a multiply + by its reciprocal (``k × 0.01``), one ulp off ``k / 100`` for about one + value in seven; its input is within an ulp of a whole cent, so no tie + rule is involved and every backend returns the scalar's float. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Table 5.6.1 / Table 3.5.4. + """ + amount = col(value) + whole_cents = ( + nw.when(amount < 0) + .then(-((-amount * 100.0 + 0.5).floor())) + .otherwise((amount * 100.0 + 0.5).floor()) + ) + return (whole_cents / 100.0).round(2) + + +def ratio(numerator: IntoExpr, denominator: IntoExpr) -> nw.Expr: + """numerator ÷ denominator; null unless the denominator is positive. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3 (every ratio it + defines has a positive denominator). + """ + den = col(denominator) + return nw.when(den > 0).then(col(numerator) / den) diff --git a/src/lead/express/benchmark.py b/src/lead/express/benchmark.py new file mode 100644 index 0000000..93061c4 --- /dev/null +++ b/src/lead/express/benchmark.py @@ -0,0 +1,350 @@ +"""Performance Year Benchmark formulas (§3) as narwhals expressions. + +Row-wise builders for a frame with one row per benchmark cell (category × +alignment type, county, base year…), plus :func:`month_weighted_mean` for +``group_by(...).agg(...)``. Scalar twins: ``lead.benchmark``. A null or +non-positive denominator gives null, never a stand-in value. + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §3. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import narwhals as nw + +from lead.benchmark import ( + CAPPED, + CAPPED_AT_UPPER, + FLOORED, + RAISED_TO_LOWER, + WITHIN_CAP, + WITHIN_GUARDRAIL, +) +from lead.express import IntoExpr, cents, col, lit_or_col, ratio +from lead.params import ( + ACPT_GUARDRAILS, + ACPT_WEIGHT, + FIRST_PERFORMANCE_YEAR, + GLOBAL, + HIGH_NEEDS, + LOWER_SPENDING, + REGIONAL_EFFICIENCY_SHARE, + RISK_SCORE_GROWTH_CAPS, + VOLUNTARY_GUARDRAIL_RATE, +) + +RISK_CAP_REFERENCE_MISSING = "risk_growth_cap_reference_missing" +_NULL = nw.lit(None, dtype=nw.Float64()) + +# ── §3.3 ────────────────────────────────────────────────────────────────── + + +def risk_standardized_pbpm( + expenditure: IntoExpr, eligible_months: IntoExpr, average_risk_score: IntoExpr +) -> nw.Expr: + """Expenditure ÷ eligible months ÷ average risk score (Table 3.3.3 + lines 3 and 5); null without positive months and score. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.3.1 / Table 3.3.3. + """ + return ratio(ratio(expenditure, eligible_months), average_risk_score) + + +def growth_rate(later: IntoExpr, earlier: IntoExpr) -> nw.Expr: + """later ÷ earlier FFS expenditures; null unless both are positive. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Box 3.3.2 Steps 1–2. + """ + return nw.when(col(later) > 0).then(ratio(later, earlier)) + + +def county_market_share(aligned_months: IntoExpr, eligible_months: IntoExpr) -> nw.Expr: + """ACO-aligned ÷ alignment-eligible months in a county. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Box 3.3.2 Step 3.1. + """ + return ratio(aligned_months, eligible_months) + + +def county_weight(aligned_months: IntoExpr, aco_aligned_months: IntoExpr) -> nw.Expr: + """A county's share of the ACO's aligned months (trend weights and + regional FFS expenditures alike). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Box 3.3.2 Step 3.2 / §3.4.1. + """ + return ratio(aligned_months, aco_aligned_months) + + +def two_way_blended_trend( + national_growth: IntoExpr, regional_growth: IntoExpr, national_weight: IntoExpr +) -> nw.Expr: + """national × weight + regional × (1 − weight). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Box 3.3.2 Step 4. + """ + weight = col(national_weight) + return col(national_growth) * weight + col(regional_growth) * (1.0 - weight) + + +def trended_weighted_pbpm( + risk_standardized: IntoExpr, trend_to_by3: IntoExpr, base_year_weight: IntoExpr +) -> nw.Expr: + """One base year's contribution to the Historical Benchmark: PBPM × + trend to BY 3 × weight (Table 3.3.3 line 9); sum it over base years. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.3.3 / Table 3.3.3. + """ + return col(risk_standardized) * col(trend_to_by3) * col(base_year_weight) + + +# ── §3.2.5 ──────────────────────────────────────────────────────────────── + + +def voluntary_guardrail( + voluntary_historical_pbpm: IntoExpr, + claims_historical_pbpm: IntoExpr, + rate: float = VOLUNTARY_GUARDRAIL_RATE, +) -> nw.Expr: + """The voluntary Historical Benchmark held within ±10 percent of the + claims-aligned one; null when either is null. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.2.5. + """ + claims = col(claims_historical_pbpm) + return nw.when(~claims.is_null()).then( + col(voluntary_historical_pbpm).clip( + lower_bound=claims * (1.0 - rate), upper_bound=claims * (1.0 + rate) + ) + ) + + +def voluntary_guardrail_status( + voluntary_historical_pbpm: IntoExpr, + claims_historical_pbpm: IntoExpr, + rate: float = VOLUNTARY_GUARDRAIL_RATE, +) -> nw.Expr: + """Which side of the guardrail the voluntary benchmark landed on; null + when either input is null. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.2.5. + """ + voluntary, claims = col(voluntary_historical_pbpm), col(claims_historical_pbpm) + return ( + nw.when(voluntary.is_null() | claims.is_null()) + .then(nw.lit(None, dtype=nw.String())) + .when(voluntary > claims * (1.0 + rate)) + .then(nw.lit(CAPPED_AT_UPPER)) + .when(voluntary < claims * (1.0 - rate)) + .then(nw.lit(RAISED_TO_LOWER)) + .otherwise(nw.lit(WITHIN_GUARDRAIL)) + ) + + +def contributes_to_voluntary_benchmark( + category: IntoExpr, by3_category: IntoExpr +) -> nw.Expr: + """A voluntarily aligned beneficiary contributes unless High Needs in + the PY without being High Needs in BY 3 (a null BY 3 category is not + High Needs). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.2.5. + """ + was_high_needs = (col(by3_category) == HIGH_NEEDS).fill_null(False) + return (col(category) != HIGH_NEEDS) | was_high_needs + + +# ── §3.4 ────────────────────────────────────────────────────────────────── + + +def regional_efficiency_eligible( + spending_designation: IntoExpr, risk_option: IntoExpr +) -> nw.Expr: + """Lower-spending ACOs in the Global Risk track. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.4.3. + """ + return (col(spending_designation) == LOWER_SPENDING) & (col(risk_option) == GLOBAL) + + +def uncapped_regional_efficiency_adjustment( + regional_ffs_pbpm: IntoExpr, historical_benchmark_pbpm: IntoExpr +) -> nw.Expr: + """50 percent of the positive regional − historical difference. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.4.3 / Table 3.4.3.1. + """ + difference = col(regional_ffs_pbpm) - col(historical_benchmark_pbpm) + return difference.clip(lower_bound=0.0) * REGIONAL_EFFICIENCY_SHARE + + +def benchmark_adjustment_cap( + uspcc_amount: IntoExpr, py_average_risk_score: IntoExpr, cap_rate: IntoExpr | float +) -> nw.Expr: + """USPCC-based amount × PY average risk score × cap rate. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.4.3 / Table 3.4.3.3. + """ + return col(uspcc_amount) * col(py_average_risk_score) * lit_or_col(cap_rate) + + +def regional_efficiency_adjustment( + regional_ffs_pbpm: IntoExpr, + historical_benchmark_pbpm: IntoExpr, + *, + eligible: IntoExpr, + uspcc_amount: IntoExpr, + py_average_risk_score: IntoExpr, + cap_rate: IntoExpr | float, +) -> nw.Expr: + """Applied REA: zero when ineligible or not positive, else the lesser + of the uncapped REA and the cap; null when a needed input is null (a + positive REA never falls back to its uncapped amount). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.4.3 / Tables + 3.4.3.1–3.4.3.4. + """ + uncapped = uncapped_regional_efficiency_adjustment( + regional_ffs_pbpm, historical_benchmark_pbpm + ) + cap = benchmark_adjustment_cap(uspcc_amount, py_average_risk_score, cap_rate) + flag = col(eligible) + return ( + nw.when(uncapped.is_null() | flag.is_null()) + .then(_NULL) + .when(~flag | (uncapped == 0)) + .then(nw.lit(0.0)) + .when(cap.is_null()) + .then(_NULL) + .otherwise(nw.min_horizontal(uncapped, cap)) + ) + + +# ── §3.5 ────────────────────────────────────────────────────────────────── + + +def _guardrail(performance_year: IntoExpr | int, side: int) -> nw.Expr: + year = lit_or_col(performance_year) + bound = None + for model_year, bounds in ACPT_GUARDRAILS.items(): + condition = year == FIRST_PERFORMANCE_YEAR + model_year - 1 + bound = (nw.when if bound is None else bound.when)(condition).then( + nw.lit(bounds[side]) + ) + return bound.otherwise(_NULL) + + +def three_way_blended_trend( + two_way_trend: IntoExpr, acpt: IntoExpr, performance_year: IntoExpr | int +) -> nw.Expr: + """Two-thirds two-way trend + one-third ACPT, held within the PY's + guardrail around the two-way trend; null for a PY outside the model. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.5.1 / Table 3.5.1. + """ + two_way = col(two_way_trend) + raw = (1.0 - ACPT_WEIGHT) * two_way + ACPT_WEIGHT * col(acpt) + lower = two_way + _guardrail(performance_year, 0) + upper = two_way + _guardrail(performance_year, 1) + return nw.when(~lower.is_null()).then( + raw.clip(lower_bound=lower, upper_bound=upper) + ) + + +def risk_score_growth_cap(category: IntoExpr) -> nw.Expr: + """The category's growth cap: 3 percent A&D and ESRD, 4 percent High + Needs; null for anything else. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.5.2. + """ + return col(category).replace_strict( + dict(RISK_SCORE_GROWTH_CAPS), default=None, return_dtype=nw.Float64() + ) + + +def capped_risk_score( + by3_risk_score: IntoExpr, py_risk_score: IntoExpr, category: IntoExpr +) -> nw.Expr: + """PY risk score held within BY 3 × (1 ± cap). Null without a BY 3 + score or a known category: the uncapped score never passes through. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.5.2. + """ + by3, cap = col(by3_risk_score), risk_score_growth_cap(category) + return nw.when(~by3.is_null() & ~cap.is_null()).then( + col(py_risk_score).clip( + lower_bound=by3 * (1.0 - cap), upper_bound=by3 * (1.0 + cap) + ) + ) + + +def risk_growth_cap_status( + by3_risk_score: IntoExpr, py_risk_score: IntoExpr, category: IntoExpr +) -> nw.Expr: + """Whether the PY risk score was capped, raised to the floor, kept, or + could not be capped for a missing reference. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.5.2. + """ + by3, py, cap = ( + col(by3_risk_score), + col(py_risk_score), + risk_score_growth_cap(category), + ) + return ( + nw.when(by3.is_null() | py.is_null() | cap.is_null()) + .then(nw.lit(RISK_CAP_REFERENCE_MISSING)) + .when(py > by3 * (1.0 + cap)) + .then(nw.lit(CAPPED)) + .when(py < by3 * (1.0 - cap)) + .then(nw.lit(FLOORED)) + .otherwise(nw.lit(WITHIN_CAP)) + ) + + +def performance_year_benchmark_pbpm( + historical_benchmark_pbpm: IntoExpr, + benchmark_adjustment: IntoExpr, + trend_factor: IntoExpr, + capped_py_risk_score: IntoExpr, +) -> nw.Expr: + """(Historical Benchmark + REA or PSA) × three-way trend × capped PY + risk score; a null adjustment stays null. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.5 / §3.5.2. + """ + adjusted = col(historical_benchmark_pbpm) + col(benchmark_adjustment) + return adjusted * col(trend_factor) * col(capped_py_risk_score) + + +def benchmark_dollars( + py_benchmark_pbpm: IntoExpr, + full_year_months: IntoExpr, + hybrid_months_and_factors: Sequence[tuple[IntoExpr, IntoExpr]] = (), +) -> nw.Expr: + """cents(PBPM × [full-year months + Σ hybrid months × factor]). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.5.3 / Table 3.5.3.2 / + Table 3.5.4 lines 8 and 22. + """ + hybrid = nw.lit(0.0) + for months, factor in hybrid_months_and_factors: + hybrid = hybrid + col(months) * col(factor) + return cents(col(py_benchmark_pbpm) * (col(full_year_months) + hybrid)) + + +def month_weighted_mean(value: IntoExpr, months: IntoExpr) -> nw.Expr: + """Aggregation: Σ value × months ÷ Σ months, over rows whose value is + known and months positive; null without such rows. Use inside + ``group_by(...).agg(...)`` (average risk scores, the claims/voluntary + PBPM combination, regional FFS expenditure). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.3.1 / §3.4.1 / + §3.5.2 / §3.5.4. + """ + amount, weight = col(value), col(months) + counted = ~amount.is_null() & (weight > 0) + numerator = nw.when(counted).then(amount * weight).otherwise(nw.lit(0.0)).sum() + denominator = nw.when(counted).then(weight).otherwise(nw.lit(0.0)).sum() + return ratio(numerator, denominator) diff --git a/src/lead/express/settlement.py b/src/lead/express/settlement.py new file mode 100644 index 0000000..a83e5ef --- /dev/null +++ b/src/lead/express/settlement.py @@ -0,0 +1,290 @@ +"""Final Financial Settlement (Table 5.6.1) as narwhals expressions. + +:func:`settle` settles a frame of scenarios at once — one row per ACO, +risk option or what-if — and returns the same lines as +:func:`lead.settlement.settle`, on any narwhals backend. The line builders +are public so a caller can assemble a partial chain. + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.2–§5.6 / Table 5.6.1. +""" + +from __future__ import annotations + +import narwhals as nw +from narwhals.typing import FrameT + +from lead.express import IntoExpr, cents, col +from lead.params import ( + DISCOUNT_RATES, + FIRST_PERFORMANCE_YEAR, + GLOBAL, + LAST_PERFORMANCE_YEAR, + PROFESSIONAL, + QUALITY_WITHHOLD_RATE, + RETENTION_INCENTIVE_RATE, + RISK_CORRIDORS, + SEQUESTRATION_RATE, +) +from lead.settlement import SettlementInputs, SettlementLines + +INPUT_COLUMNS = tuple(SettlementInputs.model_fields) +"""Columns :func:`settle` reads — the fields of ``SettlementInputs``.""" + +LINE_COLUMNS = tuple(SettlementLines.__dataclass_fields__) +"""Columns :func:`settle` writes — the fields of ``SettlementLines``.""" + +_NULL = nw.lit(None, dtype=nw.Float64()) + + +def discount_rate( + risk_option: IntoExpr, spending_designation: IntoExpr, performance_year: IntoExpr +) -> nw.Expr: + """Look up the benchmark discount rate (Table 5.6.1 line 2). + + Professional: 0.0. Global: Table 5.2.1 by designation and PY. Anything + else — an unknown option or designation, a PY outside 2027–2036 — is + null, and so is every line computed from it. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.2.1 / Table 5.2.1. + """ + option, designation, year = ( + col(risk_option), + col(spending_designation), + col(performance_year), + ) + in_model = year.is_between(FIRST_PERFORMANCE_YEAR, LAST_PERFORMANCE_YEAR) + rate = nw.when((option == PROFESSIONAL) & in_model).then(nw.lit(0.0)) + for name, by_year in DISCOUNT_RATES.items(): + for py, value in by_year.items(): + rate = rate.when( + (option == GLOBAL) & (designation == name) & (year == py) + ).then(nw.lit(value)) + return rate.otherwise(_NULL) + + +def discount_amount(py_benchmark: IntoExpr, rate: IntoExpr) -> nw.Expr: + """Total Discount = PY Benchmark × rate (Table 5.6.1 line 3). + + A null rate is the paper's "N/A" (the Professional option's Total + Discount) and removes nothing. Use it on a rate column that writes the + Professional option as null; :func:`settle` instead derives the rate + with :func:`discount_rate` and lets a null rate null the chain. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.2.1 / Table 5.2.2 line 3. + """ + return cents(col(py_benchmark) * nw.coalesce(col(rate), nw.lit(0.0))) + + +def quality_withhold(py_benchmark: IntoExpr) -> nw.Expr: + """Quality Withhold = 3 percent of the PY Benchmark before the discount + (Table 5.6.1 line 5). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.2.2 / Table 5.2.2 line 5. + """ + return cents(col(py_benchmark) * QUALITY_WITHHOLD_RATE) + + +def quality_earn_back(withhold: IntoExpr, quality_score: IntoExpr) -> nw.Expr: + """Earned Quality Withhold = withhold × quality score (line 7); null + when the score is missing or outside [0, 1]. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.2.2 / Table 5.2.2 line 7. + """ + score = col(quality_score) + return nw.when(score.is_between(0.0, 1.0)).then(cents(col(withhold) * score)) + + +def retention_incentive_adjustment( + py_benchmark: IntoExpr, applies: IntoExpr +) -> nw.Expr: + """2 percent of the PY Benchmark when the ACO terminated after its + first PY, else 0.0; null when ``applies`` is null. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.2.3. + """ + flag = col(applies) + return ( + nw.when(flag) + .then(cents(col(py_benchmark) * RETENTION_INCENTIVE_RATE)) + .when(~flag) + .then(nw.lit(0.0)) + .otherwise(_NULL) + ) + + +def adjusted_stop_loss_payout(payout: IntoExpr, factor: IntoExpr) -> nw.Expr: + """Payout × neutrality factor (Table 5.6.1 line 20). A null factor is + accepted only with a zero payout (no payouts model-wide); otherwise null. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Box 5.3.3 (Application of + Stop-Loss, line 20). + """ + amount, scale = col(payout), col(factor) + return ( + nw.when(scale.is_null() & (amount == 0)) + .then(nw.lit(0.0)) + .otherwise(cents(amount * scale)) + ) + + +def _corridor_exposure( + magnitude: nw.Expr, benchmark: nw.Expr, lower: float, upper: float | None +) -> nw.Expr: + capped = ( + magnitude if upper is None else nw.min_horizontal(magnitude, benchmark * upper) + ) + return (capped - benchmark * lower).clip(lower_bound=0.0) + + +def retained_in_corridor( + gross: IntoExpr, + benchmark_after_adjustments: IntoExpr, + risk_option: IntoExpr, + corridor: int, +) -> nw.Expr: + """Savings (losses) retained in one risk corridor, signed like ``gross`` + (the "Retained ... in Corridor n" lines of Table 5.6.1); null for an + unknown risk option or a null input. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.4 / Table 5.4.1 / + Table 5.4.3. + """ + amount, benchmark, option = ( + col(gross), + col(benchmark_after_adjustments), + col(risk_option), + ) + sign = nw.when(amount < 0).then(nw.lit(-1.0)).otherwise(nw.lit(1.0)) + expr = None + for (name, scenario), corridors in RISK_CORRIDORS.items(): + band = corridors[corridor - 1] + in_scenario = (amount >= 0) if scenario == "savings" else (amount < 0) + value = cents( + sign + * _corridor_exposure(amount.abs(), benchmark, band.lower, band.upper) + * band.rate + ) + condition = (option == name) & in_scenario & ~benchmark.is_null() + expr = (nw.when if expr is None else expr.when)(condition).then(value) + return expr.otherwise(_NULL) + + +def sequestration_amount(shared: IntoExpr) -> nw.Expr: + """2 percent of Shared Savings; zero on Shared Losses (line 27). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.4 / Table 5.4.2 line 27. + """ + return cents(col(shared).clip(lower_bound=0.0) * SEQUESTRATION_RATE) + + +@nw.narwhalify +def settle(scenarios: FrameT) -> FrameT: + """Settle a frame of scenarios: add every Table 5.6.1 line as a column. + + Reads the ``SettlementInputs`` fields (``INPUT_COLUMNS``) and writes the + ``SettlementLines`` fields (``LINE_COLUMNS``), overwriting the Stop-Loss + input columns with lines 17–19 (zero charge and payout, null factor, + for a row that did not elect Stop-Loss). Other columns pass through. + + A missing input column raises. Unlike ``SettlementInputs``, no row is + refused: a null or invalid input nulls the lines that depend on it. A + row the scalar accepts settles to exactly what + :func:`lead.settlement.settle` returns. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.6 / Table 5.6.1. + """ + present = set(scenarios.collect_schema().names()) + missing = [c for c in INPUT_COLUMNS if c not in present] + if missing: + raise ValueError(f"settle() needs columns {missing}") + c = nw.col + elected = c("stop_loss_elected") + return ( + scenarios.with_columns( + discount_rate=discount_rate( + "risk_option", "spending_designation", "performance_year" + ), + retention_incentive_adjustment=retention_incentive_adjustment( + "py_benchmark", "retention_incentive_applies" + ), + quality_withhold=quality_withhold("py_benchmark"), + total_ffs_payments=cents( + c("participant_provider_claims") + + c("preferred_provider_claims") + + c("non_aco_provider_claims") + ), + adjusted_stop_loss_payout=nw.when(elected) + .then( + adjusted_stop_loss_payout( + "stop_loss_payout", "stop_loss_neutrality_factor" + ) + ) + .when(~elected) + .then(nw.lit(0.0)), + ) + .with_columns( + discount_amount=cents(c("py_benchmark") * c("discount_rate")), + quality_earn_back=quality_earn_back("quality_withhold", "quality_score"), + py_expenditure=cents(c("capitation_payments") + c("total_ffs_payments")), + stop_loss_charge=nw.when(elected) + .then(c("stop_loss_charge")) + .when(~elected) + .then(nw.lit(0.0)), + stop_loss_payout=nw.when(elected) + .then(c("stop_loss_payout")) + .when(~elected) + .then(nw.lit(0.0)), + stop_loss_neutrality_factor=nw.when(elected).then( + c("stop_loss_neutrality_factor") + ), + ) + .with_columns( + benchmark_after_discount=cents(c("py_benchmark") - c("discount_amount")), + net_quality_withhold=cents(c("quality_withhold") - c("quality_earn_back")), + net_stop_loss_impact=cents( + c("stop_loss_charge") - c("adjusted_stop_loss_payout") + ), + ) + .with_columns( + benchmark_after_adjustments=cents( + c("benchmark_after_discount") + - c("retention_incentive_adjustment") + - c("net_quality_withhold") + ), + py_expenditure_after_stop_loss=cents( + c("py_expenditure") + c("net_stop_loss_impact") + ), + ) + .with_columns( + gross_savings_losses=cents( + c("benchmark_after_adjustments") - c("py_expenditure_after_stop_loss") + ) + ) + .with_columns( + **{ + f"retained_corridor_{n}": retained_in_corridor( + "gross_savings_losses", + "benchmark_after_adjustments", + "risk_option", + n, + ) + for n in (1, 2, 3, 4) + } + ) + .with_columns( + shared_savings_losses=cents( + c("retained_corridor_1") + + c("retained_corridor_2") + + c("retained_corridor_3") + + c("retained_corridor_4") + ) + ) + .with_columns( + sequestration_amount=sequestration_amount("shared_savings_losses") + ) + .with_columns( + shared_savings_losses_after_sequestration=cents( + c("shared_savings_losses") - c("sequestration_amount") + ) + ) + ) diff --git a/src/lead/express/stop_loss.py b/src/lead/express/stop_loss.py new file mode 100644 index 0000000..693390e --- /dev/null +++ b/src/lead/express/stop_loss.py @@ -0,0 +1,89 @@ +"""Beneficiary-grain Stop-Loss (Box 5.3.3) as narwhals expressions. + +One row per beneficiary: Predicted Expenditure, Residual Expenditure, the +distance to the Attachment Point, the banded payout and the band reached. +Scalar twins: ``lead.stop_loss``. + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.3.3 / Box 5.3.3. +""" + +from __future__ import annotations + +import narwhals as nw + +from lead.express import IntoExpr, cents, col +from lead.params import STOP_LOSS_BANDS +from lead.stop_loss import BELOW_ATTACHMENT_POINT + + +def predicted_expenditure( + regional_expenditure_pbpm: IntoExpr, risk_score: IntoExpr, months_aligned: IntoExpr +) -> nw.Expr: + """Predicted Expenditure = regional expenditure PBPM × risk score × + months of alignment in the PY. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Box 5.3.3 (Stop-Loss + Residual Expenditure). + """ + return cents(col(regional_expenditure_pbpm) * col(risk_score) * col(months_aligned)) + + +def residual_expenditure(actual_spend: IntoExpr, predicted_spend: IntoExpr) -> nw.Expr: + """Residual Expenditure = PY expenditure − Predicted Expenditure. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.3.3 / Box 5.3.3. + """ + return cents(col(actual_spend) - col(predicted_spend)) + + +def distance_to_attachment_point( + attachment_point: IntoExpr, residual: IntoExpr +) -> nw.Expr: + """Attachment Point − Residual: positive is headroom before Stop-Loss + attaches, negative is how far past it the beneficiary is. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.3.3 (protection begins + once the Residual passes the Attachment Point). + """ + return cents(col(attachment_point) - col(residual)) + + +def stop_loss_payout(residual: IntoExpr, attachment_point: IntoExpr) -> nw.Expr: + """Banded payout on the Residual above the Attachment Point: 80 percent + up to 200 percent of it, 100 percent beyond; null if either is null. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Box 5.3.3 (Stop-Loss + Payment Schedule; illustration: $400,000 residual, $150,000 AP → $220,000). + """ + amount, point = col(residual), col(attachment_point) + paid = [] + for band in STOP_LOSS_BANDS: + capped = ( + amount + if band.end_multiple is None + else nw.min_horizontal(amount, point * band.end_multiple) + ) + exposure = (capped - point * band.start_multiple).clip(lower_bound=0.0) + paid.append(cents(exposure * band.payout_rate)) + total = paid[0] + for band_payout in paid[1:]: + total = total + band_payout + return nw.when(~amount.is_null() & ~point.is_null()).then(cents(total)) + + +def band_status(residual: IntoExpr, attachment_point: IntoExpr) -> nw.Expr: + """The band the Residual reaches: ``band_2``, ``band_1`` or + ``below_attachment_point``; null if either input is null. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Box 5.3.3 (Stop-Loss + Payment Schedule). + """ + amount, point = col(residual), col(attachment_point) + status = nw.when(amount.is_null() | point.is_null()).then( + nw.lit(None, dtype=nw.String()) + ) + for band in reversed(STOP_LOSS_BANDS): + status = status.when(amount > point * band.start_multiple).then( + nw.lit(f"band_{band.band}") + ) + return status.otherwise(nw.lit(BELOW_ATTACHMENT_POINT)) diff --git a/src/lead/money.py b/src/lead/money.py new file mode 100644 index 0000000..46990cf --- /dev/null +++ b/src/lead/money.py @@ -0,0 +1,36 @@ +"""Cent rounding for LEAD dollar lines. + +The paper prints every dollar line rounded to the cent and carries PBPMs, +rates and factors at full precision; its worked examples only reproduce +that way (Table 3.5.4 line 24 sums the cent-rounded category totals of +lines 8 and 22; Table 3.3.3 and Table 3.5.3.2 multiply unrounded PBPMs). +So every function in this package that produces a dollar line rounds it +with :func:`cents`, and nothing else is rounded. + +Rounding is half away from zero on the amount scaled to cents +(``floor(|x| × 100 + 0.5)``). ``lead.express.cents`` performs the same +float operations, so scalar and frame results agree bit for bit on every +backend — unlike the engines' own ``round``, which differ (polars rounds +half to even, DuckDB half away from zero). + +Source: LEAD PY2027 methodology (bib E4PEU3HV) Table 5.6.1 / Table 3.5.4. +""" + +from __future__ import annotations + +import math + + +def cents(amount: float) -> float: + """Round a dollar amount to the cent, half away from zero. + + >>> cents(184_593.6544) + 184593.65 + >>> cents(-0.125) + -0.13 + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Table 5.6.1. + """ + scaled = math.floor(abs(amount) * 100.0 + 0.5) + # ``+ 0.0`` turns a negative zero into zero. + return math.copysign(scaled, amount) / 100.0 + 0.0 diff --git a/src/lead/monies_owed.py b/src/lead/monies_owed.py new file mode 100644 index 0000000..47c61b2 --- /dev/null +++ b/src/lead/monies_owed.py @@ -0,0 +1,108 @@ +"""LEAD Total Monies Owed (§5.5, Table 5.5.1). + +Final Shared Savings/Losses settle as an adjustment to what Provisional +Settlement already paid or collected; the payment-arrangement under (over) +payments and the High-Performers Pool incentive are added. Every amount is +signed the way Table 5.5.1 prints it: positive is owed to the ACO, +negative is owed to CMS. + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.5 / Table 5.5.1. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from lead.money import cents + + +def apo_adjustment(fee_reductions: float, apo_payments: float) -> float: + """Reconcile the Advanced Payment Option against actual claims + reductions: reductions above the APO payments are credited to the ACO + (positive), reductions below them are recouped by CMS (negative). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.5 (Adjustments for + ACOs participating in EPCC and/or APO). + """ + return cents(fee_reductions - apo_payments) + + +@dataclass(frozen=True) +class PaymentArrangementAdjustments: + """Table 5.5.1 line 4 and its three component lines.""" + + capitation_under_over_payment: float + enhanced_pcc_repayment: float + apo_adjustment: float + payment_arrangement_adjustments: float + + +def payment_arrangement_adjustments( + *, + capitation_under_over_payment: float, + enhanced_pcc_paid: float, + apo_adjustment: float, +) -> PaymentArrangementAdjustments: + """Return Under (Over) Payments from Payment Arrangements (line 4). + + ``capitation_under_over_payment`` is positive when CMS underpaid the + ACO's capitation. Enhanced PCC is recouped in full, so its repayment is + ``-enhanced_pcc_paid``. An ACO that elected neither EPCC nor APO (the + TCC ACO of Table 5.5.1, footnote 1) passes zero for both. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.5 / Table 5.5.1 line 4. + """ + repayment = cents(-enhanced_pcc_paid) + return PaymentArrangementAdjustments( + capitation_under_over_payment=capitation_under_over_payment, + enhanced_pcc_repayment=repayment, + apo_adjustment=apo_adjustment, + payment_arrangement_adjustments=cents( + capitation_under_over_payment + repayment + apo_adjustment + ), + ) + + +@dataclass(frozen=True) +class TotalMoniesOwed: + """Table 5.5.1 lines 1–7.""" + + provisional_shared_savings_losses: float + final_shared_savings_losses: float + shared_savings_losses_owed: float + payment_arrangement_adjustments: float + high_performers_pool_incentive: float + adjustments_owed: float + total_monies_owed: float + + +def total_monies_owed( + *, + provisional_shared_savings_losses: float, + final_shared_savings_losses: float, + payment_arrangement_adjustments: float, + high_performers_pool_incentive: float, +) -> TotalMoniesOwed: + """Return Total Monies Owed at Final Settlement (lines 1–7). + + Line 3 = final − provisional Shared Savings (Losses); line 6 = payment + arrangement adjustments + High-Performers Pool incentive; line 7 = + line 3 + line 6. Every input is required: an ACO that did not select + Provisional Settlement, or earned no High-Performers Pool incentive, + passes zero for it. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.5 / Table 5.5.1. + """ + owed = cents(final_shared_savings_losses - provisional_shared_savings_losses) + adjustments = cents( + payment_arrangement_adjustments + high_performers_pool_incentive + ) + return TotalMoniesOwed( + provisional_shared_savings_losses=provisional_shared_savings_losses, + final_shared_savings_losses=final_shared_savings_losses, + shared_savings_losses_owed=owed, + payment_arrangement_adjustments=payment_arrangement_adjustments, + high_performers_pool_incentive=high_performers_pool_incentive, + adjustments_owed=adjustments, + total_monies_owed=cents(owed + adjustments), + ) diff --git a/src/lead/params.py b/src/lead/params.py new file mode 100644 index 0000000..4a9a2b5 --- /dev/null +++ b/src/lead/params.py @@ -0,0 +1,332 @@ +"""LEAD PY2027 financial-methodology parameters, transcribed from the public paper. + +Every constant here is printed in the CMS "LEAD Model PY 2027 Alignment and +Financial Methodology Paper" v1.2 (bib ``E4PEU3HV``). Values that CMS +computes per year or per ACO and does not print — the Stop-Loss Attachment +Point, ACPT values, hybrid alignment factors, modified USPCC amounts, the +Stop-Loss neutrality factor — are caller inputs, never constants. Worked- +example figures are test fixtures, never parameters. + +The mappings are read-only (``MappingProxyType``) so a caller cannot change +a published rate for everyone in the process. +""" + +from __future__ import annotations + +from types import MappingProxyType +from typing import Final, Literal + +from pydantic import BaseModel, ConfigDict, Field + +# ── Labels ──────────────────────────────────────────────────────────────── + +RiskOption = Literal["Global", "Professional"] +GLOBAL: Final = "Global" +PROFESSIONAL: Final = "Professional" +RISK_OPTIONS: Final = (GLOBAL, PROFESSIONAL) +"""LEAD risk-sharing options. Source: LEAD PY2027 methodology (bib E4PEU3HV) §1.3.""" + +SpendingDesignation = Literal["Lower-spending", "Higher-spending"] +LOWER_SPENDING: Final = "Lower-spending" +HIGHER_SPENDING: Final = "Higher-spending" +SPENDING_DESIGNATIONS: Final = (LOWER_SPENDING, HIGHER_SPENDING) +"""Spending designations. Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.4.2.""" + +BeneficiaryCategory = Literal["A&D", "ESRD", "High Needs"] +AGED_DISABLED: Final = "A&D" +ESRD: Final = "ESRD" +HIGH_NEEDS: Final = "High Needs" +BENEFICIARY_CATEGORIES: Final = (AGED_DISABLED, ESRD, HIGH_NEEDS) +"""Beneficiary categories. Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.2.2.""" + +EntryType = Literal["Newly Entering", "Renewing"] +NEWLY_ENTERING: Final = "Newly Entering" +RENEWING: Final = "Renewing" +ENTRY_TYPES: Final = (NEWLY_ENTERING, RENEWING) +"""ACO entry types. Source: LEAD PY2027 methodology (bib E4PEU3HV) §1.1.""" + +BASE_YEARS: Final = ("BY1", "BY2", "BY3") +"""The three historical base years, oldest first. + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.2 / §3.3.3. +""" + +# ── Model calendar ──────────────────────────────────────────────────────── + +FIRST_PERFORMANCE_YEAR: Final = 2027 +LAST_PERFORMANCE_YEAR: Final = 2036 +"""LEAD runs ten Performance Years, PY 2027 through PY 2036. + +Source: LEAD PY2027 methodology (bib E4PEU3HV) Table 5.2.1 (2032−2036) / +Table 2.6.1 (PY 1–10). +""" + + +def model_year(performance_year: int) -> int: + """Return the model PY index (PY 2027 = 1, PY 2036 = 10). + + Raises ``ValueError`` outside the model's ten Performance Years. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Table 2.6.1 / Table 3.5.1. + """ + if not FIRST_PERFORMANCE_YEAR <= performance_year <= LAST_PERFORMANCE_YEAR: + raise ValueError( + f"performance year {performance_year} is outside the LEAD model " + f"({FIRST_PERFORMANCE_YEAR}-{LAST_PERFORMANCE_YEAR})" + ) + return performance_year - FIRST_PERFORMANCE_YEAR + 1 + + +# ── §5.2 Performance Year Benchmark adjustments ─────────────────────────── + +DISCOUNT_RATES: Final = MappingProxyType( + { + LOWER_SPENDING: MappingProxyType({year: 0.03 for year in range(2027, 2037)}), + HIGHER_SPENDING: MappingProxyType( + { + 2027: 0.0175, + 2028: 0.02, + 2029: 0.0225, + 2030: 0.025, + 2031: 0.0275, + **{year: 0.03 for year in range(2032, 2037)}, + } + ), + } +) +"""Global Risk Option benchmark discount by spending designation and PY. + +Lower-spending ACOs: a static 3.0 percent. Higher-spending ACOs: 1.75 percent +in 2027, rising 0.25 points a year to 3.0 percent in 2032−2036. The +Professional option carries no discount (Table 5.2.2 line 2: 0.00%). + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.2.1 / Table 5.2.1. +""" + +QUALITY_WITHHOLD_RATE: Final = 0.03 +"""Quality Withhold: 3 percent of the PY Benchmark before the discount. + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.2.2 / Table 5.2.2 line 5. +""" + +RETENTION_INCENTIVE_RATE: Final = 0.02 +"""Retention incentive: 2 percent benchmark reduction for an ACO that +terminates after its first Performance Year. + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.2.3. +""" + +# ── §5.3.3 Stop-Loss Reinsurance ────────────────────────────────────────── + + +class StopLossBand(BaseModel): + """One Stop-Loss payout band, in multiples of the Attachment Point. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.3.3 / Box 5.3.3 + (Stop-Loss Payment Schedule). + """ + + model_config = ConfigDict(frozen=True) + + band: int = Field(ge=1) + start_multiple: float = Field(ge=0.0, description="Band start, × Attachment Point.") + end_multiple: float | None = Field( + default=None, description="Band end, × Attachment Point; None = no upper limit." + ) + payout_rate: float = Field(ge=0.0, le=1.0) + + +STOP_LOSS_BANDS: Final = ( + StopLossBand(band=1, start_multiple=1.0, end_multiple=2.0, payout_rate=0.80), + StopLossBand(band=2, start_multiple=2.0, end_multiple=None, payout_rate=1.00), +) +"""Residual Expenditure between 100 and 200 percent of the Attachment Point +pays at 80 percent; above 200 percent it pays at 100 percent. + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.3.3 / Box 5.3.3 +(Stop-Loss Payment Schedule). +""" + +STOP_LOSS_ATTACHMENT_PAYOUT_SHARE: Final = 0.02 +"""CMS sets each reference year's Attachment Point so that total model +payout equals 2 percent of total Medicare expenditures. Recorded for +reference: the Attachment Point itself is a CMS input to this package. + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.3.3 / Box 5.3.3 +(Stop-Loss Attachment Point). +""" + +STOP_LOSS_REFERENCE_YEAR_COUNT: Final = 3 +STOP_LOSS_REFERENCE_YEAR_GAP: Final = 1 +"""Three Stop-Loss reference years, with a one-year gap before the PY +(2023, 2024, 2025 for PY 2027). + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.3.3 / Box 5.3.3 +(Stop-Loss Attachment Point). +""" + +# ── §5.4 Risk corridors and sequestration ───────────────────────────────── + +CorridorScenario = Literal["savings", "losses"] + + +class Corridor(BaseModel): + """One risk corridor: a band of gross savings or losses, as a share of + the PY Benchmark after adjustments, shared at ``rate``. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.4 / Table 5.4.1 / + Table 5.4.3. + """ + + model_config = ConfigDict(frozen=True) + + corridor: int = Field(ge=1) + lower: float = Field(ge=0.0, description="Band start, share of benchmark.") + upper: float | None = Field( + default=None, description="Band end, share of benchmark; None = no limit." + ) + rate: float = Field(ge=0.0, le=1.0) + + +def _corridors( + bounds: tuple[float, ...], rates: tuple[float, ...] +) -> tuple[Corridor, ...]: + edges = (0.0, *bounds, None) + return tuple( + Corridor(corridor=i + 1, lower=edges[i], upper=edges[i + 1], rate=rate) + for i, rate in enumerate(rates) + ) + + +_GLOBAL_BOUNDS = (0.15, 0.35, 0.50) +_PROFESSIONAL_BOUNDS = (0.10, 0.15, 0.20) + +RISK_CORRIDORS: Final = MappingProxyType( + { + (GLOBAL, "savings"): _corridors(_GLOBAL_BOUNDS, (1.00, 0.50, 0.25, 0.10)), + (GLOBAL, "losses"): _corridors(_GLOBAL_BOUNDS, (1.00, 0.50, 0.25, 0.10)), + (PROFESSIONAL, "savings"): _corridors( + _PROFESSIONAL_BOUNDS, (0.60, 0.35, 0.15, 0.05) + ), + (PROFESSIONAL, "losses"): _corridors( + _PROFESSIONAL_BOUNDS, (0.50, 0.35, 0.15, 0.05) + ), + } +) +"""Risk corridors by (risk option, savings/losses). + +Global: up to 15% / 15–35% / 35–50% / over 50% of benchmark at 100 / 50 / +25 / 10 percent, savings and losses alike. Professional: up to 10% / 10–15% / +15–20% / over 20% at 60 (savings) or 50 (losses) / 35 / 15 / 5 percent. + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.4.1 / Table 5.4.1; +§5.4.2 / Table 5.4.3. +""" + +SEQUESTRATION_RATE: Final = 0.02 +"""Sequestration: 2 percent of the Shared Savings retained by the ACO +(Budget Control Act of 2011); it never applies to Shared Losses. + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.4 / Table 5.4.2 line 27. +""" + +# ── §3 Benchmark ───────────────────────────────────────────────────────── + +BASE_YEAR_WEIGHTS: Final = MappingProxyType( + { + NEWLY_ENTERING: MappingProxyType({"BY1": 0.10, "BY2": 0.30, "BY3": 0.60}), + RENEWING: MappingProxyType({"BY1": 1 / 3, "BY2": 1 / 3, "BY3": 1 / 3}), + } +) +"""Base-year weights when all three base years are sufficient. + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.3.3 / Table 3.3.3 line 8. +""" + +ACPT_WEIGHT: Final = 1 / 3 +"""Share of the three-way blended trend set by the ACPT; the observed +national-regional blend carries the other two-thirds. + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.5.1. +""" + +ACPT_GUARDRAILS: Final = MappingProxyType( + { + 1: (-0.002, 0.003), + 2: (-0.004, 0.006), + 3: (-0.006, 0.009), + 4: (-0.008, 0.012), + **{year: (-0.010, 0.015) for year in range(5, 11)}, + } +) +"""(lower, upper) guardrail on the three-way trend factor, in factor units +(percentage points / 100) around the two-way blend, by model PY. + +The §3.5.1 prose widens the lower bound one year later than Table 3.5.1 +("-0.4 percentage points in 2029"); the table is followed here. + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.5.1 / Table 3.5.1. +""" + +RISK_SCORE_GROWTH_CAPS: Final = MappingProxyType( + {AGED_DISABLED: 0.03, ESRD: 0.03, HIGH_NEEDS: 0.04} +) +"""Symmetric PY risk-score growth cap relative to BY 3, by category. + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.5.2 (Risk Score Growth Cap). +""" + +REGIONAL_EFFICIENCY_SHARE: Final = 0.50 +"""Regional Efficiency Adjustment: 50 percent of the positive gap between +BY 3 regional FFS expenditures and the Historical Benchmark. + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.4.3 / Table 3.4.3.1. +""" + +PRIOR_SAVINGS_SHARE: Final = 0.50 +PRIOR_SAVINGS_PRORATION_CAP: Final = 1.0 +"""Prior Savings Adjustment: 50 percent of the prorated average PBPM +savings; the proration factor is capped at 100 percent. + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.4.4. +""" + +BENCHMARK_ADJUSTMENT_CAP_RATE: Final = 0.05 +REDUCED_BENCHMARK_ADJUSTMENT_CAP_RATE: Final = 0.03 +"""Benchmark adjustment cap: 5 percent of the modified USPCC amount, risk +adjusted; 3 percent for lower-spending ACOs with more than 40 percent of +Participant TINs in a Shared Savings Program ACO in the prior two years +(CMS determines that eligibility; it is a caller input here). + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.4 / §3.4.3. +""" + +VOLUNTARY_GUARDRAIL_RATE: Final = 0.10 +"""The voluntarily aligned Historical Benchmark stays within ±10 percent of +the claims-aligned Historical Benchmark of the same category. + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.2.5. +""" + +VOLUNTARY_BENCHMARK_MINIMUM: Final = 500 +"""Fewer than 500 contributing voluntarily aligned beneficiaries in BY 3: +the claims-aligned benchmarks apply to the voluntarily aligned population. + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §3.2.5. +""" + +AlignmentMinimumType = Literal["standard", "newly_entering", "high_needs_esrd"] + +BASE_YEAR_ALIGNMENT_MINIMUMS: Final = MappingProxyType( + { + "standard": (3000,) * 10, + "newly_entering": (600, 1200, 1800, 2400) + (3000,) * 6, + "high_needs_esrd": (500, 625, 750, 825) + (1000,) * 6, + } +) +"""Claims-based alignment minimum a base year must meet to count toward +the Historical Benchmark, by the CMS-determined alignment standard and +model PY 1–10. + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §2.6 / Table 2.6.1; §3.3.4. +""" diff --git a/src/lead/settlement.py b/src/lead/settlement.py new file mode 100644 index 0000000..4ce7ef8 --- /dev/null +++ b/src/lead/settlement.py @@ -0,0 +1,476 @@ +"""LEAD Final Financial Settlement: Table 5.6.1, line by line. + +The chain is: PY Benchmark (line 1) → Global discount (lines 2–4) → +retention incentive (§5.2.3) → Quality Withhold and Earn Back (lines 5–9) +→ PY expenditure (lines 10–15) → Stop-Loss (lines 16–22) → gross +savings/losses (lines 23–25) → risk corridors (line 26 and its corridor +lines) → sequestration (lines 27–28). :func:`settle` runs it for one +scenario; ``lead.express.settlement.settle`` runs it for a frame of +scenarios on any narwhals backend and returns the same numbers. + +Every dollar line is rounded to the cent as it is produced +(:func:`lead.money.cents`); rates and the quality score are not rounded. +The paper prints reductions in parentheses in Table 5.6.1 (lines 5 and 8); +here they are positive amounts that the chain subtracts. + +Missing inputs are never silent zeros: :class:`SettlementInputs` refuses +an incomplete scenario. The zeros that do appear are the paper's own: the +Professional option's discount ("N/A", Table 5.6.1 line 3), the Stop-Loss +lines of an ACO that did not elect Stop-Loss (§5.3.3), and sequestration of +Shared Losses (§5.4). + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.2–§5.6 / Table 5.6.1. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from lead.money import cents +from lead.params import ( + DISCOUNT_RATES, + GLOBAL, + PROFESSIONAL, + QUALITY_WITHHOLD_RATE, + RETENTION_INCENTIVE_RATE, + RISK_CORRIDORS, + SEQUESTRATION_RATE, + CorridorScenario, + RiskOption, + SpendingDesignation, + model_year, +) +from lead.stop_loss import apply_stop_loss, without_stop_loss + +# ── §5.2 Performance Year Benchmark adjustments ─────────────────────────── + + +def benchmark_discount_rate( + risk_option: RiskOption, + spending_designation: SpendingDesignation | None, + performance_year: int, +) -> float: + """Return the benchmark discount rate for a risk option, designation and PY. + + The Professional option has no discount (0.0). The Global option reads + Table 5.2.1; an unknown designation or a year outside the model raises, + because a Global benchmark with no scheduled discount is not computable. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.2.1 / Table 5.2.1 / + Table 5.2.2 line 2. + """ + model_year(performance_year) + if risk_option == PROFESSIONAL: + return 0.0 + if risk_option != GLOBAL: + raise ValueError(f"unknown risk option {risk_option!r}") + if spending_designation not in DISCOUNT_RATES: + raise ValueError( + "the Global discount needs a spending designation, " + f"got {spending_designation!r}" + ) + return DISCOUNT_RATES[spending_designation][performance_year] + + +@dataclass(frozen=True) +class Discount: + """Table 5.6.1 lines 2–4.""" + + discount_rate: float + discount_amount: float + benchmark_after_discount: float + + +def apply_discount(py_benchmark: float, discount_rate: float | None) -> Discount: + """Reduce the PY Benchmark by the discount (Table 5.6.1 lines 2–4). + + A missing rate (``None``) is the paper's "N/A" — the Professional + option's Total Discount — and removes nothing. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.2.1 / Table 5.2.2 + lines 2–4. + """ + rate = 0.0 if discount_rate is None else discount_rate + amount = cents(py_benchmark * rate) + return Discount(rate, amount, cents(py_benchmark - amount)) + + +def quality_withhold( + py_benchmark: float, withhold_rate: float = QUALITY_WITHHOLD_RATE +) -> float: + """Return the Quality Withhold: 3 percent of the PY Benchmark before + the discount (Table 5.6.1 line 5). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.2.2 / Table 5.2.2 line 5. + """ + return cents(py_benchmark * withhold_rate) + + +def quality_earn_back(withhold: float, quality_score: float) -> float: + """Return the Earned Quality Withhold: withhold × quality score + (Table 5.6.1 line 7). The score is a share between 0 and 1. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.2.2 / Table 5.2.2 line 7. + """ + if not 0.0 <= quality_score <= 1.0: + raise ValueError(f"quality score must be within [0, 1], got {quality_score}") + return cents(withhold * quality_score) + + +def retention_incentive_adjustment(py_benchmark: float, applies: bool) -> float: + """Return the retention incentive reduction: 2 percent of the PY + Benchmark when the ACO terminated after its first Performance Year, + otherwise zero (the ACO earns it back). + + The paper says the PY Benchmark is "reduced by 2 percent" and orders the + reduction after the discount and before the Quality Withhold; like the + Quality Withhold it is taken here on the PY Benchmark before the + discount. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.2.3. + """ + return cents(py_benchmark * RETENTION_INCENTIVE_RATE) if applies else 0.0 + + +@dataclass(frozen=True) +class BenchmarkAdjustments: + """Table 5.6.1 lines 1–9 (plus the §5.2.3 retention incentive).""" + + py_benchmark: float + discount_rate: float + discount_amount: float + benchmark_after_discount: float + retention_incentive_adjustment: float + quality_withhold: float + quality_score: float + quality_earn_back: float + net_quality_withhold: float + benchmark_after_adjustments: float + + +def adjust_benchmark( + py_benchmark: float, + discount_rate: float | None, + quality_score: float, + *, + retention_incentive_applies: bool = False, +) -> BenchmarkAdjustments: + """Apply the discount, retention incentive and net Quality Withhold to + the PY Benchmark (Table 5.6.1 lines 1–9). + + Line 9 = line 4 − retention incentive − (line 5 − line 7). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.2 / Table 5.2.2 / + Table 5.6.1 lines 1–9. + """ + discount = apply_discount(py_benchmark, discount_rate) + retention = retention_incentive_adjustment( + py_benchmark, retention_incentive_applies + ) + withhold = quality_withhold(py_benchmark) + earned = quality_earn_back(withhold, quality_score) + net_quality = cents(withhold - earned) + return BenchmarkAdjustments( + py_benchmark=py_benchmark, + discount_rate=discount.discount_rate, + discount_amount=discount.discount_amount, + benchmark_after_discount=discount.benchmark_after_discount, + retention_incentive_adjustment=retention, + quality_withhold=withhold, + quality_score=quality_score, + quality_earn_back=earned, + net_quality_withhold=net_quality, + benchmark_after_adjustments=cents( + discount.benchmark_after_discount - retention - net_quality + ), + ) + + +# ── §5.3 Performance Year expenditure ───────────────────────────────────── + + +@dataclass(frozen=True) +class PYExpenditure: + """Table 5.6.1 lines 10–15.""" + + capitation_payments: float + participant_provider_claims: float + preferred_provider_claims: float + non_aco_provider_claims: float + total_ffs_payments: float + py_expenditure: float + + +def py_expenditure( + capitation_payments: float, + participant_provider_claims: float, + preferred_provider_claims: float, + non_aco_provider_claims: float, +) -> PYExpenditure: + """Sum capitation and the three FFS claim components into the PY + expenditure, on a pre-sequestration basis (Table 5.6.1 lines 10–15). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.3 / Table 5.3.2. + """ + total_ffs = cents( + participant_provider_claims + + preferred_provider_claims + + non_aco_provider_claims + ) + return PYExpenditure( + capitation_payments=capitation_payments, + participant_provider_claims=participant_provider_claims, + preferred_provider_claims=preferred_provider_claims, + non_aco_provider_claims=non_aco_provider_claims, + total_ffs_payments=total_ffs, + py_expenditure=cents(capitation_payments + total_ffs), + ) + + +# ── §5.4 Gross savings/losses, risk corridors, sequestration ────────────── + + +def gross_savings_losses( + benchmark_after_adjustments: float, py_expenditure_after_stop_loss: float +) -> float: + """Return gross savings (positive) or losses (negative): benchmark after + adjustments less PY expenditure after Stop-Loss (Table 5.6.1 line 25). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.4 / Table 5.4.2 line 25. + """ + return cents(benchmark_after_adjustments - py_expenditure_after_stop_loss) + + +@dataclass(frozen=True) +class SharedSavingsLosses: + """Table 5.6.1 line 26 and its "Retained ... in Corridor n" lines.""" + + risk_option: RiskOption + scenario: CorridorScenario + retained_by_corridor: tuple[float, ...] + shared_savings_losses: float + + +def corridor_exposure( + amount: float, benchmark: float, lower: float, upper: float | None +) -> float: + """Return the part of ``|amount|`` that falls in one corridor, whose + bounds are shares of ``benchmark``. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.4 / Table 5.4.1 / + Table 5.4.3. + """ + magnitude = abs(amount) + capped = magnitude if upper is None else min(magnitude, benchmark * upper) + return max(0.0, capped - benchmark * lower) + + +def shared_savings_losses( + gross: float, benchmark_after_adjustments: float, risk_option: RiskOption +) -> SharedSavingsLosses: + """Apply the risk corridors to gross savings or losses (Table 5.6.1 + line 26). + + Corridor bounds are shares of the PY Benchmark after the discount, + Quality Withhold and Earn Back. Each corridor's retained amount keeps + the sign of ``gross`` and is rounded to the cent; line 26 is their sum, + so the printed corridor lines add up to it. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.4 / Table 5.4.1 / + Table 5.4.3 / Table 5.4.2 / Table 5.4.4. + """ + if risk_option not in (GLOBAL, PROFESSIONAL): + raise ValueError(f"unknown risk option {risk_option!r}") + scenario: CorridorScenario = "savings" if gross >= 0 else "losses" + sign = 1.0 if gross >= 0 else -1.0 + retained = tuple( + cents( + sign + * corridor_exposure(gross, benchmark_after_adjustments, c.lower, c.upper) + * c.rate + ) + for c in RISK_CORRIDORS[(risk_option, scenario)] + ) + return SharedSavingsLosses(risk_option, scenario, retained, cents(sum(retained))) + + +def sequestration_amount(shared: float, rate: float = SEQUESTRATION_RATE) -> float: + """Return sequestration: 2 percent of Shared Savings; zero on Shared + Losses, which are not sequestered (Table 5.6.1 line 27). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.4 / Table 5.4.2 line 27. + """ + return cents(max(shared, 0.0) * rate) + + +# ── The whole chain ─────────────────────────────────────────────────────── + + +class SettlementInputs(BaseModel): + """One settlement scenario: the Table 5.6.1 inputs. + + ``spending_designation`` is required for the Global option (it selects + the discount) and unused for the Professional option. The Stop-Loss + charge and payout are required exactly when Stop-Loss was elected; the + neutrality factor is required unless the payout is zero. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Table 5.6.1 lines 1, 6, + 10–13, 17–19; §5.2.3. + """ + + model_config = ConfigDict(frozen=True) + + py_benchmark: float = Field(description="Line 1: PY Benchmark, all aligned.") + risk_option: RiskOption + spending_designation: SpendingDesignation | None = None + performance_year: int + quality_score: float = Field(ge=0.0, le=1.0, description="Line 6, as a share.") + capitation_payments: float = Field(description="Line 10.") + participant_provider_claims: float = Field(description="Line 11.") + preferred_provider_claims: float = Field(description="Line 12.") + non_aco_provider_claims: float = Field(description="Line 13.") + stop_loss_elected: bool + stop_loss_charge: float | None = Field(default=None, description="Line 17.") + stop_loss_payout: float | None = Field(default=None, description="Line 18.") + stop_loss_neutrality_factor: float | None = Field( + default=None, description="Line 19 (model-wide)." + ) + retention_incentive_applies: bool = Field( + default=False, + description="True only when the ACO terminated after its first PY (§5.2.3).", + ) + + @model_validator(mode="after") + def _complete(self) -> SettlementInputs: + model_year(self.performance_year) + if self.risk_option == GLOBAL and self.spending_designation is None: + raise ValueError("the Global option needs a spending designation") + amounts = (self.stop_loss_charge, self.stop_loss_payout) + if self.stop_loss_elected: + if None in amounts: + raise ValueError( + "Stop-Loss was elected: charge and payout are required" + ) + if self.stop_loss_neutrality_factor is None and self.stop_loss_payout: + raise ValueError("a Stop-Loss payout needs the neutrality factor") + elif amounts != (None, None) or self.stop_loss_neutrality_factor is not None: + raise ValueError("Stop-Loss amounts given but Stop-Loss was not elected") + return self + + +@dataclass(frozen=True) +class SettlementLines: + """Every line of Table 5.6.1 (line 16 = line 15, line 23 = line 22, + line 24 = line 9), plus the §5.2.3 retention incentive.""" + + py_benchmark: float # 1 + discount_rate: float # 2 + discount_amount: float # 3 + benchmark_after_discount: float # 4 + retention_incentive_adjustment: float # §5.2.3 + quality_withhold: float # 5 + quality_score: float # 6 + quality_earn_back: float # 7 + net_quality_withhold: float # 8 + benchmark_after_adjustments: float # 9, 24 + capitation_payments: float # 10 + participant_provider_claims: float # 11 + preferred_provider_claims: float # 12 + non_aco_provider_claims: float # 13 + total_ffs_payments: float # 14 + py_expenditure: float # 15, 16 + stop_loss_charge: float # 17 + stop_loss_payout: float # 18 + stop_loss_neutrality_factor: float | None # 19 + adjusted_stop_loss_payout: float # 20 + net_stop_loss_impact: float # 21 + py_expenditure_after_stop_loss: float # 22, 23 + gross_savings_losses: float # 25 + shared_savings_losses: float # 26 + retained_corridor_1: float + retained_corridor_2: float + retained_corridor_3: float + retained_corridor_4: float + sequestration_amount: float # 27 + shared_savings_losses_after_sequestration: float # 28 + + +def settle(inputs: SettlementInputs) -> SettlementLines: + """Run Final Financial Settlement for one scenario (Table 5.6.1). + + An ACO that did not elect Stop-Loss has no charge, payout or net + impact: its PY expenditure after Stop-Loss is its PY expenditure. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.6 / Table 5.6.1. + """ + rate = benchmark_discount_rate( + inputs.risk_option, inputs.spending_designation, inputs.performance_year + ) + benchmark = adjust_benchmark( + inputs.py_benchmark, + rate, + inputs.quality_score, + retention_incentive_applies=inputs.retention_incentive_applies, + ) + expenditure = py_expenditure( + inputs.capitation_payments, + inputs.participant_provider_claims, + inputs.preferred_provider_claims, + inputs.non_aco_provider_claims, + ) + # SettlementInputs guarantees the charge and payout exist when elected. + stop_loss = ( + apply_stop_loss( + expenditure.py_expenditure, + inputs.stop_loss_charge, + inputs.stop_loss_payout, + inputs.stop_loss_neutrality_factor, + ) + if inputs.stop_loss_elected + else without_stop_loss(expenditure.py_expenditure) + ) + gross = gross_savings_losses( + benchmark.benchmark_after_adjustments, + stop_loss.py_expenditure_after_stop_loss, + ) + shared = shared_savings_losses( + gross, benchmark.benchmark_after_adjustments, inputs.risk_option + ) + sequestration = sequestration_amount(shared.shared_savings_losses) + c1, c2, c3, c4 = shared.retained_by_corridor + return SettlementLines( + py_benchmark=benchmark.py_benchmark, + discount_rate=benchmark.discount_rate, + discount_amount=benchmark.discount_amount, + benchmark_after_discount=benchmark.benchmark_after_discount, + retention_incentive_adjustment=benchmark.retention_incentive_adjustment, + quality_withhold=benchmark.quality_withhold, + quality_score=benchmark.quality_score, + quality_earn_back=benchmark.quality_earn_back, + net_quality_withhold=benchmark.net_quality_withhold, + benchmark_after_adjustments=benchmark.benchmark_after_adjustments, + capitation_payments=expenditure.capitation_payments, + participant_provider_claims=expenditure.participant_provider_claims, + preferred_provider_claims=expenditure.preferred_provider_claims, + non_aco_provider_claims=expenditure.non_aco_provider_claims, + total_ffs_payments=expenditure.total_ffs_payments, + py_expenditure=expenditure.py_expenditure, + stop_loss_charge=stop_loss.stop_loss_charge, + stop_loss_payout=stop_loss.stop_loss_payout, + stop_loss_neutrality_factor=stop_loss.stop_loss_neutrality_factor, + adjusted_stop_loss_payout=stop_loss.adjusted_stop_loss_payout, + net_stop_loss_impact=stop_loss.net_stop_loss_impact, + py_expenditure_after_stop_loss=stop_loss.py_expenditure_after_stop_loss, + gross_savings_losses=gross, + shared_savings_losses=shared.shared_savings_losses, + retained_corridor_1=c1, + retained_corridor_2=c2, + retained_corridor_3=c3, + retained_corridor_4=c4, + sequestration_amount=sequestration, + shared_savings_losses_after_sequestration=cents( + shared.shared_savings_losses - sequestration + ), + ) diff --git a/src/lead/stop_loss.py b/src/lead/stop_loss.py new file mode 100644 index 0000000..b4cee44 --- /dev/null +++ b/src/lead/stop_loss.py @@ -0,0 +1,324 @@ +"""LEAD Stop-Loss Reinsurance (§5.3.3, Box 5.3.3). + +Per beneficiary: Predicted Expenditure = regional expenditure PBPM × risk +score × months of alignment; Residual Expenditure = PY expenditure − +Predicted Expenditure; once the Residual passes the Attachment Point it +pays out by the banded schedule. Per ACO: the charge is the trended, +risk-adjusted reference expenditure × the 3-year average payout +percentage; at settlement the neutrality-adjusted payout nets against the +charge, and the net is added to PY expenditure (Table 5.6.1 lines 16–22). + +CMS supplies the Attachment Point, the reference-year expenditure and +payout percentages, and the model-wide neutrality factor; none of them is +a constant here. + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.3.3 / Box 5.3.3. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +from lead.money import cents +from lead.params import ( + STOP_LOSS_BANDS, + STOP_LOSS_REFERENCE_YEAR_COUNT, + STOP_LOSS_REFERENCE_YEAR_GAP, + StopLossBand, +) + +BELOW_ATTACHMENT_POINT = "below_attachment_point" + + +def reference_years(performance_year: int) -> tuple[int, ...]: + """Return the three Stop-Loss reference years: the most recent three + before the PY, with a one-year gap (2023–2025 for PY 2027). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Box 5.3.3 (Stop-Loss + Attachment Point). + """ + last = performance_year - STOP_LOSS_REFERENCE_YEAR_GAP - 1 + return tuple(range(last - STOP_LOSS_REFERENCE_YEAR_COUNT + 1, last + 1)) + + +def trended_attachment_point( + reference_attachment_point: float, national_trend_factor: float +) -> float: + """Trend the most recent reference year's Attachment Point to PY + dollars with the national trend factor. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Box 5.3.3 (Stop-Loss + Attachment Point). + """ + return cents(reference_attachment_point * national_trend_factor) + + +def predicted_expenditure( + regional_expenditure_pbpm: float, risk_score: float, months_aligned: float +) -> float: + """Return Predicted Expenditure = LEAD regional expenditure PBPM × + beneficiary risk score × months of alignment in the PY. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Box 5.3.3 (Stop-Loss + Residual Expenditure). + """ + return cents(regional_expenditure_pbpm * risk_score * months_aligned) + + +def residual_expenditure(actual_spend: float, predicted_spend: float) -> float: + """Return Residual Expenditure = PY expenditure − Predicted Expenditure. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.3.3 / Box 5.3.3 + (illustration line 4). + """ + return cents(actual_spend - predicted_spend) + + +@dataclass(frozen=True) +class BandPayout: + """One band of a beneficiary's Stop-Loss payout.""" + + band: int + exposure: float + payout_rate: float + payout: float + + +@dataclass(frozen=True) +class StopLossPayout: + """The illustration table of Box 5.3.3 for one beneficiary.""" + + residual_expenditure: float + attachment_point: float + below_attachment_point: float + bands: tuple[BandPayout, ...] + total_payout: float + + +def band_exposure( + residual: float, attachment_point: float, band: StopLossBand +) -> float: + """Return the Residual Expenditure that falls inside one band. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Box 5.3.3 (Stop-Loss + Payment Schedule). + """ + capped = ( + residual + if band.end_multiple is None + else min(residual, attachment_point * band.end_multiple) + ) + return max(0.0, capped - attachment_point * band.start_multiple) + + +def stop_loss_payout( + residual: float, + attachment_point: float, + bands: Sequence[StopLossBand] = STOP_LOSS_BANDS, +) -> StopLossPayout: + """Pay the Residual Expenditure above the Attachment Point by band. + + Nothing is covered below the Attachment Point; band 1 (100–200 percent + of it) pays 80 percent and band 2 (above 200 percent) pays 100 percent. + A $400,000 Residual against a $150,000 Attachment Point pays $220,000. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Box 5.3.3 (Stop-Loss + Payout; illustration lines 1–5). + """ + exposures = [band_exposure(residual, attachment_point, band) for band in bands] + paid = tuple( + BandPayout( + band=band.band, + exposure=cents(exposure), + payout_rate=band.payout_rate, + payout=cents(exposure * band.payout_rate), + ) + for band, exposure in zip(bands, exposures, strict=True) + ) + return StopLossPayout( + residual_expenditure=residual, + attachment_point=attachment_point, + below_attachment_point=cents(min(max(residual, 0.0), attachment_point)), + bands=paid, + total_payout=cents(sum(band.payout for band in paid)), + ) + + +def band_status(residual: float, attachment_point: float) -> str: + """Name the band the Residual Expenditure reaches: ``band_2``, + ``band_1`` or ``below_attachment_point``. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Box 5.3.3 (Stop-Loss + Payment Schedule). + """ + for band in reversed(STOP_LOSS_BANDS): + if residual > attachment_point * band.start_multiple: + return f"band_{band.band}" + return BELOW_ATTACHMENT_POINT + + +def payout_percentage( + total_payout: float, reference_expenditure: float +) -> float | None: + """Return a reference year's payout percentage: total Stop-Loss payout + ÷ total reference-year expenditure (None without expenditure). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Box 5.3.3 (Stop-Loss + Charge). + """ + return total_payout / reference_expenditure if reference_expenditure > 0 else None + + +def average_payout_percentage(percentages: Sequence[float | None]) -> float | None: + """Return the simple average of the reference years' payout + percentages, or None if any year is missing. + + Not rounded: Box 5.3.3 prints 2.03% but its charge (line 6) only + reproduces with the unrounded mean of 1.96%, 2.09% and 2.05%. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Box 5.3.3 (Calculation of + Stop-Loss Charge, line 5). + """ + known = [p for p in percentages if p is not None] + if not known or len(known) != len(percentages): + return None + return sum(known) / len(known) + + +@dataclass(frozen=True) +class StopLossCharge: + """Box 5.3.3, Calculation of Stop-Loss Charge, lines 1–6.""" + + average_reference_expenditure_pbpm_trended: float + aligned_beneficiary_months: float + average_risk_score: float + trended_risk_adjusted_reference_expenditure: float + average_payout_percentage: float + stop_loss_charge: float + + +def stop_loss_charge( + average_reference_expenditure_pbpm_trended: float, + aligned_beneficiary_months: float, + average_risk_score: float, + average_payout_pct: float, +) -> StopLossCharge: + """Return the PY Stop-Loss charge: trended reference PBPM × aligned + months × average PY risk score (line 4), × average payout percentage + (line 6). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Box 5.3.3 (Calculation of + Stop-Loss Charge). + """ + reference = cents( + average_reference_expenditure_pbpm_trended + * aligned_beneficiary_months + * average_risk_score + ) + return StopLossCharge( + average_reference_expenditure_pbpm_trended=average_reference_expenditure_pbpm_trended, + aligned_beneficiary_months=aligned_beneficiary_months, + average_risk_score=average_risk_score, + trended_risk_adjusted_reference_expenditure=reference, + average_payout_percentage=average_payout_pct, + stop_loss_charge=cents(reference * average_payout_pct), + ) + + +def charge_pbpm(charge: float, aligned_beneficiary_months: float) -> float | None: + """Express the charge per aligned beneficiary month (None without months). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.3.3 (the charge is a + per-beneficiary per-month amount). + """ + return charge / aligned_beneficiary_months if aligned_beneficiary_months else None + + +def neutrality_factor( + total_model_charges: float, total_model_payouts: float +) -> float | None: + """Return the Stop-Loss Neutrality Factor: all LEAD Stop-Loss charges ÷ + all LEAD Stop-Loss payouts in the PY. + + With no payouts model-wide the ratio is undefined and None is returned; + every ACO's payout is then zero, so nothing needs scaling. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Box 5.3.3 (Stop-Loss + Payout Adjustment Factor). + """ + return total_model_charges / total_model_payouts if total_model_payouts else None + + +@dataclass(frozen=True) +class StopLossApplication: + """Table 5.6.1 lines 16–22.""" + + py_expenditure: float + stop_loss_charge: float + stop_loss_payout: float + stop_loss_neutrality_factor: float | None + adjusted_stop_loss_payout: float + net_stop_loss_impact: float + py_expenditure_after_stop_loss: float + + +def adjusted_payout(payout: float, factor: float | None) -> float: + """Scale the ACO's payout by the neutrality factor (Table 5.6.1 line 20). + + A None factor is accepted only with a zero payout. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Box 5.3.3 (Application of + Stop-Loss, line 20). + """ + if factor is None: + if payout: + raise ValueError("a Stop-Loss payout needs the neutrality factor") + return 0.0 + return cents(payout * factor) + + +def apply_stop_loss( + py_expenditure: float, + charge: float, + payout: float, + factor: float | None, +) -> StopLossApplication: + """Add the net of charge and neutrality-adjusted payout to the PY + expenditure (Table 5.6.1 lines 16–22). + + Stop-Loss is virtual: the charge increases PY expenditure and the + payout reduces it. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) Box 5.3.3 (Application of + Stop-Loss). + """ + adjusted = adjusted_payout(payout, factor) + net = cents(charge - adjusted) + return StopLossApplication( + py_expenditure=py_expenditure, + stop_loss_charge=charge, + stop_loss_payout=payout, + stop_loss_neutrality_factor=factor, + adjusted_stop_loss_payout=adjusted, + net_stop_loss_impact=net, + py_expenditure_after_stop_loss=cents(py_expenditure + net), + ) + + +def without_stop_loss(py_expenditure: float) -> StopLossApplication: + """Lines 16–22 for an ACO that did not elect Stop-Loss: no charge, no + payout, and PY expenditure unchanged. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.3.3 (only ACOs that + elected Stop-Loss have PY expenditures adjusted). + """ + return StopLossApplication( + py_expenditure=py_expenditure, + stop_loss_charge=0.0, + stop_loss_payout=0.0, + stop_loss_neutrality_factor=None, + adjusted_stop_loss_payout=0.0, + net_stop_loss_impact=0.0, + py_expenditure_after_stop_loss=py_expenditure, + ) diff --git a/src/lead/timing.py b/src/lead/timing.py new file mode 100644 index 0000000..376f9db --- /dev/null +++ b/src/lead/timing.py @@ -0,0 +1,139 @@ +"""LEAD settlement timing and which basis a settlement run states. + +Final Financial Settlement includes claims run out through March 31 of the +year after the PY; Provisional Settlement, before then, applies a +completion factor instead (§5.1 / Table 5.1.1). So a settlement computed +before the run-out closes states a projection, and partial-year actual +claims are labelled as such, never the headline. + +A run declares one of three modes: ``projection`` (may state a labelled +projection), ``actual`` (every PY number must come from PY data; a run +before the run-out closes fails), or ``rewind_test`` (development only, +behaves as ``projection``). Which basis a run may use before the run-out +closes is a run-configuration choice, not a CMS rule; every result names +the basis it used. + +Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.1 / Table 5.1.1. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from typing import Final + +PROJECTION: Final = "projection" +ACTUAL: Final = "actual" +REWIND_TEST: Final = "rewind_test" +RUN_MODES: Final = (PROJECTION, ACTUAL, REWIND_TEST) + +PROJECTED_BASIS: Final = "projected_from_base_year_experience" +ACTUAL_CLAIMS_BASIS: Final = "actual_performance_year_claims" +PROJECTED_STATUS: Final = "projected" +PARTIAL_YEAR_STATUS: Final = "actual_to_date_partial_year" +COMPUTED_STATUS: Final = "computed_from_calculated_inputs" +PY_CLAIMS_INPUT: Final = "performance_year_claims_by_provider_class" +RUNOUT_REMEDY: Final = ( + "wait for the Performance Year claims run-out (through Q1 of the following " + "year) or run in projection mode" +) + +CMS_SETTLEMENT_REPORT: Final = "cms_settlement_report" +CMS_SETTLEMENT_REPORT_MISSING: Final = "cms_settlement_report_source_missing" +PROVISIONAL_NOT_YET_MADE: Final = "provisional_settlement_not_yet_made" +FINAL_NOT_YET_MADE: Final = "final_settlement_not_yet_made" + + +def validate_run_mode(run_mode: str) -> str: + """Return the run mode, or raise naming the three modes. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.1 (run-configuration + choice, not a CMS rule). + """ + if run_mode not in RUN_MODES: + raise ValueError( + f"run_mode must be one of {', '.join(RUN_MODES)}; got {run_mode!r}" + ) + return run_mode + + +def require_projection_allowed(run_mode: str, input_name: str, remedy: str) -> None: + """Raise in ``actual`` mode, where a PY input without rows may not be + replaced by a projection; ``projection`` and ``rewind_test`` continue. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.1 (run-configuration + choice, not a CMS rule). + """ + if validate_run_mode(run_mode) == ACTUAL: + raise ValueError( + f"{input_name} has no complete performance-year rows as of the as-of " + f"date and run_mode={ACTUAL!r} forbids a projection; {remedy}" + ) + + +def settlement_runout_end(performance_year: int) -> date: + """Return the last day of the Final Settlement claims run-out: March 31 + of the year after the PY. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.1 / Table 5.1.1 + (Claims Run-Out). + """ + return date(performance_year + 1, 3, 31) + + +@dataclass(frozen=True) +class SettlementBasis: + """The basis a run states, the status of its headline rows and of its + actual-claims rows.""" + + basis: str + headline_status: str + actual_rows_status: str + as_of_date: date + + +def settlement_basis_for_run( + run_mode: str, as_of_date: date, performance_year: int, py_claim_rows: int +) -> SettlementBasis: + """Decide the basis of one settlement run. + + Actual once PY claim rows exist and the run-out has closed; before that + a projection (refused in ``actual`` mode), with any partial-year actual + rows labelled ``actual_to_date_partial_year``. PY claim rows observed + before the PY starts are data from the future: a feed error. + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.1 / Table 5.1.1. + """ + if py_claim_rows and as_of_date < date(performance_year, 1, 1): + raise ValueError( + f"{PY_CLAIMS_INPUT}: {py_claim_rows} performance-period rows dated after " + f"the as-of date {as_of_date.isoformat()}; data from the future is a " + "feed error" + ) + if py_claim_rows and as_of_date > settlement_runout_end(performance_year): + return SettlementBasis( + ACTUAL_CLAIMS_BASIS, COMPUTED_STATUS, COMPUTED_STATUS, as_of_date + ) + require_projection_allowed(run_mode, PY_CLAIMS_INPUT, RUNOUT_REMEDY) + rows_status = PARTIAL_YEAR_STATUS if py_claim_rows else PROJECTED_STATUS + return SettlementBasis(PROJECTED_BASIS, PROJECTED_STATUS, rows_status, as_of_date) + + +def settlement_stage_input( + as_of_date: date, stage_start: date, reported: float | None, not_yet_status: str +) -> tuple[float | None, str]: + """Return a settlement-stage amount and its status. + + The amount is CMS's settlement-report figure when supplied; zero while + the as-of date precedes the stage (nothing has been settled yet); and + otherwise missing (None) until the report arrives. Used for what + Provisional Settlement distributed (Table 5.5.1 line 1) and the + High-Performers Pool incentive (line 5). + + Source: LEAD PY2027 methodology (bib E4PEU3HV) §5.1 / §5.5. + """ + if reported is not None: + return float(reported), CMS_SETTLEMENT_REPORT + if as_of_date < stage_start: + return 0.0, not_yet_status + return None, CMS_SETTLEMENT_REPORT_MISSING diff --git a/tests/lead/__init__.py b/tests/lead/__init__.py new file mode 100644 index 0000000..dd16a9f --- /dev/null +++ b/tests/lead/__init__.py @@ -0,0 +1,6 @@ +"""Tests for lead — the LEAD Model PY2027 financial methodology. + +The expected values are the worked examples CMS printed in the LEAD PY2027 +methodology paper (bib E4PEU3HV), transcribed in ``tests/lead/paper.py``; +every Expr test runs on polars and on DuckDB through narwhals. +""" diff --git a/tests/lead/conftest.py b/tests/lead/conftest.py new file mode 100644 index 0000000..d4ac003 --- /dev/null +++ b/tests/lead/conftest.py @@ -0,0 +1,44 @@ +"""Backends for the lead Expr tests: every frame test runs on polars and on +DuckDB through narwhals.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import duckdb +import narwhals as nw +import polars as pl +import pytest + + +class Backend: + """Build a native frame on one backend and read results back as rows.""" + + def __init__(self, name: str) -> None: + self.name = name + self.con = duckdb.connect() if name == "duckdb" else None + + def frame(self, data: Any, schema: dict[str, Any] | None = None) -> Any: + df = pl.DataFrame(data, schema=schema) + return df if self.con is None else self.con.from_arrow(df.to_arrow()) + + def rows(self, native: Any) -> list[dict[str, Any]]: + return nw.from_native(native).lazy().collect("polars").to_native().to_dicts() + + def select(self, data: Any, schema: dict[str, Any] | None = None, **exprs: nw.Expr): + frame = nw.from_native(self.frame(data, schema)) + return self.rows(frame.select(**exprs).to_native()) + + def close(self) -> None: + if self.con is not None: + self.con.close() + + +@pytest.fixture(params=["polars", "duckdb"]) +def backend(request: pytest.FixtureRequest) -> Iterator[Backend]: + engine = Backend(request.param) + try: + yield engine + finally: + engine.close() diff --git a/tests/lead/paper.py b/tests/lead/paper.py new file mode 100644 index 0000000..674bf1e --- /dev/null +++ b/tests/lead/paper.py @@ -0,0 +1,250 @@ +"""Worked examples printed in the LEAD PY2027 methodology (bib E4PEU3HV). + +Test fixtures only — never runtime parameters. Each value is copied from +the paper as printed; percentages are written as shares. +""" + +from __future__ import annotations + +# ── Table 5.6.1: Full Settlement Calculation ────────────────────────────── + +TABLE_5_6_1_INPUTS = { + "py_benchmark": 150_000_000.00, # line 1 + "spending_designation": "Lower-spending", # 3.00% discount (line 2) + "performance_year": 2027, + "quality_score": 0.95, # line 6 + "capitation_payments": 10_000_000.00, # line 10 + "participant_provider_claims": 1_003_442.00, # line 11 + "preferred_provider_claims": 33_435_084.00, # line 12 + "non_aco_provider_claims": 91_355_457.00, # line 13 + "stop_loss_elected": True, + "stop_loss_charge": 2_948_334.28, # line 17 + "stop_loss_payout": 2_900_000.00, # line 18 + "stop_loss_neutrality_factor": 0.930, # line 19 + "retention_incentive_applies": False, +} + +# (paper line, SettlementLines field). Lines 16, 23 and 24 restate 15, 22 +# and 9; "26.1"–"26.4" are the "Retained ... in Corridor n" lines. +TABLE_5_6_1_LINES = ( + ("1", "py_benchmark"), + ("2", "discount_rate"), + ("3", "discount_amount"), + ("4", "benchmark_after_discount"), + ("5", "quality_withhold"), + ("6", "quality_score"), + ("7", "quality_earn_back"), + ("8", "net_quality_withhold"), + ("9", "benchmark_after_adjustments"), + ("10", "capitation_payments"), + ("11", "participant_provider_claims"), + ("12", "preferred_provider_claims"), + ("13", "non_aco_provider_claims"), + ("14", "total_ffs_payments"), + ("15", "py_expenditure"), + ("16", "py_expenditure"), + ("17", "stop_loss_charge"), + ("18", "stop_loss_payout"), + ("19", "stop_loss_neutrality_factor"), + ("20", "adjusted_stop_loss_payout"), + ("21", "net_stop_loss_impact"), + ("22", "py_expenditure_after_stop_loss"), + ("23", "py_expenditure_after_stop_loss"), + ("24", "benchmark_after_adjustments"), + ("25", "gross_savings_losses"), + ("26", "shared_savings_losses"), + ("26.1", "retained_corridor_1"), + ("26.2", "retained_corridor_2"), + ("26.3", "retained_corridor_3"), + ("26.4", "retained_corridor_4"), + ("27", "sequestration_amount"), + ("28", "shared_savings_losses_after_sequestration"), +) + +_COMMON = { + "1": 150_000_000.00, + "5": 4_500_000.00, # printed ($4,500,000.00): a reduction + "6": 0.95, + "7": 4_275_000.00, + "8": 225_000.00, # printed ($225,000.00): a reduction + "10": 10_000_000.00, + "11": 1_003_442.00, + "12": 33_435_084.00, + "13": 91_355_457.00, + "14": 125_793_983.00, + "15": 135_793_983.00, + "16": 135_793_983.00, + "17": 2_948_334.28, + "18": 2_900_000.00, + "19": 0.930, + "20": 2_697_000.00, + "21": 251_334.28, + "22": 136_045_317.28, + "23": 136_045_317.28, + "26.2": 0.00, + "26.3": 0.00, + "26.4": 0.00, +} + +TABLE_5_6_1 = { + "Global": { + **_COMMON, + "2": 0.03, + "3": 4_500_000.00, + "4": 145_500_000.00, + "9": 145_275_000.00, + "24": 145_275_000.00, + "25": 9_229_682.72, + "26": 9_229_682.72, + "26.1": 9_229_682.72, + "27": 184_593.65, + "28": 9_045_089.07, + }, + "Professional": { + **_COMMON, + "2": 0.00, + "3": 0.00, # printed "N/A" + "4": 150_000_000.00, + "9": 149_775_000.00, + "24": 149_775_000.00, + "25": 13_729_682.72, + "26": 8_237_809.63, + "26.1": 8_237_809.63, + "27": 164_756.19, + "28": 8_073_053.44, + }, +} + +# ── Table 5.5.1: Calculation of Total Monies Owed (Professional, TCC) ───── + +TABLE_5_5_1 = { + "1": 6_456_540.00, # Provisional Settlement Shared Savings (Losses) + "2": 8_073_053.43, # Final Settlement Shared Savings (Losses) + "3": 1_616_513.43, # Shared Savings (Losses) Owed + "4": 160_700.00, # Under (Over) Payments from Payment Arrangements + "4.capitation": 160_700.00, + "5": 100_000.00, # High-Performers Pool Incentive + "6": 260_700.00, # Adjustments Owed + "7": 1_877_213.43, # Total Monies Owed +} + +# ── Box 5.3.3: Stop-Loss charge and payout illustration ─────────────────── + +STOP_LOSS_CHARGE = { + "1": 946.97, # Average Reference Year Expenditure PBPM, Trended + "2": 132_000, # ACO-Aligned Beneficiary Months in PY + "3": 1.160, # ACO Average Risk Score in PY + "4": 145_000_046.40, # Total Trended, Risk-Adjusted Reference Expenditure + "5": 0.0203, # 3-Year Average Payout Percentage, printed 2.03% + "RY": (0.0196, 0.0209, 0.0205), + "6": 2_948_334.28, # PY Stop-Loss Charge +} + +STOP_LOSS_PAYOUT = { + "attachment_point": 150_000.00, + "predicted": 100_000.00, + "actual": 500_000.00, + "residual": 400_000.00, + "below_attachment_point": 150_000.00, + "band_1_exposure": 150_000.00, + "band_2_exposure": 100_000.00, + "total_payout": 220_000.00, + "band_1_payout": 120_000.00, + "band_2_payout": 100_000.00, +} + +# ── Table 3.3.3: Historical Benchmark Calculation (Newly Entering, A&D) ─── + +TABLE_3_3_3 = { + # base year: (payments, months, risk, line 3, line 5, trend, line 7, weight, line 9) + "BY1": (87_856_003.26, 91_366, 1.122, 961.58, 857.03, 1.104, 946.16, 0.100, 94.62), + "BY2": (93_375_409.42, 94_577, 1.115, 987.30, 885.47, 1.076, 952.76, 0.300, 285.83), + "BY3": ( + 106_794_359.82, + 104_671, + 1.087, + 1_020.29, + 938.63, + 1.000, + 938.63, + 0.600, + 563.18, + ), + "historical_benchmark": 943.62, +} + +# ── Table 3.4.2 / Tables 3.4.3.1–3.4.3.4 ────────────────────────────────── + +# category: (regional FFS PBPM, Historical Benchmark PBPM, BY 3 weight, +# uncapped REA, USPCC, PY risk score, REA cap, applied REA, +# Adjusted Historical Benchmark) +REA_TABLES = { + "A&D": (1_050, 1_000, 0.80, 25, 1_000, 1.02, 51, 25, 1_025), + "ESRD": (8_000, 8_500, 0.05, 0, 8_000, 0.99, 396, 0, 8_500), + "High Needs": (3_700, 3_200, 0.15, 250, 3_500, 1.03, 180.25, 180.25, 3_380.25), +} +TABLE_3_4_2_DIFFERENCE = 90.0 + +# ── Table 3.5.3.2: Hybrid Alignment Benchmark Adjustment ────────────────── + +TABLE_3_5_3_2 = { + "1": 1_125.00, + "2": 60_000, + "3": 4_500, + "4": 1.0099, + "5": 1_136.14, + "6": 67_500_000.00, + "7": 5_112_618.75, + "8": 72_612_618.75, +} + +# ── Table 3.5.4: Combined Benchmark Calculation ─────────────────────────── + +# category: (adjusted HB PBPM, trend, PY risk, full months, hybrid months, +# hybrid factor, line 8) +TABLE_3_5_4_CLAIMS = { + "ESRD": (10_175.00, 1.034, 1.007, 981, 120, 1.0099, 11_677_237.29), + "A&D": (796.00, 1.042, 1.045, 56_677, 11_570, 1.0085, 59_238_767.92), + "High Needs": (3_739.00, 1.037, 1.020, 9_806, 2_864, 1.0111, 50_234_182.06), +} +# category: (adjusted HB PBPM, trend, PY risk, full+Q1 months, +# (Q2, Q3, Q4 months), (Q2, Q3, Q4 factors), line 22) +TABLE_3_5_4_VOLUNTARY = { + "ESRD": ( + 9_543.00, + 1.034, + 0.990, + 421, + (76, 62, 51), + (1.0099, 1.0236, 1.0352), + 5_998_140.95, + ), + "A&D": ( + 716.00, + 1.042, + 0.866, + 5_514, + (825, 753, 538), + (1.0085, 1.0247, 1.0431), + 4_961_259.64, + ), + "High Needs": ( + 4_109.00, + 1.037, + 1.061, + 2_393, + (734, 549, 240), + (1.0111, 1.0233, 1.0527), + 17_855_909.55, + ), +} +TABLE_3_5_4_TOTALS = { + "8": 121_150_187.27, + "22": 28_815_310.14, + "24": { + "ESRD": 17_675_378.24, + "A&D": 64_200_027.56, + "High Needs": 68_090_091.61, + "TOTAL": 149_965_497.41, + }, +} diff --git a/tests/lead/test_benchmark.py b/tests/lead/test_benchmark.py new file mode 100644 index 0000000..1187eb7 --- /dev/null +++ b/tests/lead/test_benchmark.py @@ -0,0 +1,412 @@ +"""lead.benchmark reproduces the §3 worked examples: Box 3.3.2, Table 3.3.3, +Table 3.4.2, Tables 3.4.3.1–3.4.3.9, §3.5.1–3.5.2, Table 3.5.3.2 and +Table 3.5.4.""" + +from __future__ import annotations + +import pytest + +from lead.benchmark import ( + CAPPED, + CAPPED_AT_UPPER, + FLOORED, + RAISED_TO_LOWER, + WITHIN_CAP, + WITHIN_GUARDRAIL, + base_year_alignment_minimum, + base_year_is_sufficient, + base_year_weights, + benchmark_adjustment_cap, + benchmark_adjustment_cap_rate, + benchmark_dollars, + capped_risk_score, + contributes_to_voluntary_benchmark, + cumulative_acpt, + expenditure_pbpm, + growth_rate, + historical_benchmark, + hybrid_adjustment_factor, + month_weighted_average, + national_trend_weight, + performance_year_benchmark_pbpm, + prior_savings_adjustment, + prior_savings_cap, + prior_savings_pbpm, + ratio, + regional_efficiency_adjustment, + regional_efficiency_eligible, + regional_ffs_expenditure, + risk_standardize, + spending_designation, + three_way_blended_trend, + total_benchmark, + two_way_blended_trend, + voluntary_benchmark_minimum_met, + voluntary_guardrail, +) +from lead.money import cents +from tests.lead.paper import ( + REA_TABLES, + TABLE_3_3_3, + TABLE_3_4_2_DIFFERENCE, + TABLE_3_5_3_2, + TABLE_3_5_4_CLAIMS, + TABLE_3_5_4_TOTALS, + TABLE_3_5_4_VOLUNTARY, +) + +# ── Box 3.3.2 and Table 3.3.3 ───────────────────────────────────────────── + + +def test_box_3_3_2_national_regional_blended_trend(): + national = growth_rate(1_120, 1_000) + regional = growth_rate(1_155, 1_050) + weight = national_trend_weight([(20_000, 100_000), (5_000, 50_000)]) + assert (national, round(regional, 3), round(weight, 6)) == (1.12, 1.1, 0.18) + trend = two_way_blended_trend(national, regional, weight) + assert round(trend, 3) == 1.104 # printed 1.104; the text's "1.103" is a slip + + +def test_trend_inputs_without_positive_denominators_are_missing(): + assert growth_rate(0, 1_000) is None + assert growth_rate(1_000, 0) is None + assert national_trend_weight([]) is None + assert national_trend_weight([(10, 0)]) is None + assert two_way_blended_trend(None, 1.1, 0.2) is None + assert ratio(None, 1.0) is None + + +@pytest.mark.parametrize("year", ["BY1", "BY2", "BY3"]) +def test_table_3_3_3_lines_3_to_9(year): + payments, months, risk, line3, line5, trend, line7, weight, line9 = TABLE_3_3_3[ + year + ] + pbpm = expenditure_pbpm(payments, months) + standardized = risk_standardize(pbpm, risk) + # PBPMs are printed to the cent but carried unrounded: line 5 is + # $857.03 from the unrounded line 3, $857.02 from the printed $961.58. + assert cents(pbpm) == line3 + assert cents(standardized) == line5 + assert cents(standardized * trend) == line7 + assert cents(standardized * trend * weight) == line9 + + +def test_table_3_3_3_historical_benchmark(): + years = ("BY1", "BY2", "BY3") + standardized = { + y: risk_standardize(expenditure_pbpm(*TABLE_3_3_3[y][:2]), TABLE_3_3_3[y][2]) + for y in years + } + trends = {y: TABLE_3_3_3[y][5] for y in years} + weights = base_year_weights("Newly Entering", years) + benchmark = historical_benchmark(standardized, trends, weights) + assert cents(benchmark) == TABLE_3_3_3["historical_benchmark"] + # The printed line 9 cents sum to 943.63: the total is unrounded too. + assert sum(TABLE_3_3_3[y][8] for y in years) == pytest.approx(943.63) + + +def test_historical_benchmark_needs_every_weighted_year(): + assert historical_benchmark({"BY3": 900.0}, {"BY3": 1.0}, {}) is None + assert historical_benchmark({}, {"BY3": 1.0}, {"BY3": 1.0}) is None + assert historical_benchmark({"BY3": 900.0}, {"BY3": 1.0}, {"BY3": 1.0}) == 900.0 + + +def test_risk_standardize_needs_a_positive_score(): + assert risk_standardize(1_000.0, 0.0) is None + assert expenditure_pbpm(1_000.0, 0) is None + + +# ── §3.3.3–3.3.4 base-year weights and sufficiency ──────────────────────── + + +@pytest.mark.parametrize( + "entry,years,weights", + [ + ("Newly Entering", ("BY1", "BY2", "BY3"), {"BY1": 0.1, "BY2": 0.3, "BY3": 0.6}), + ("Renewing", ("BY1", "BY2", "BY3"), {"BY1": 1 / 3, "BY2": 1 / 3, "BY3": 1 / 3}), + ("Newly Entering", ("BY1", "BY3"), {"BY1": 1 / 3, "BY3": 2 / 3}), + ("Renewing", ("BY2", "BY3"), {"BY2": 0.5, "BY3": 0.5}), + ("Renewing", ("BY2",), {"BY2": 1.0}), + ("Newly Entering", (), {}), + ], +) +def test_base_year_weights(entry, years, weights): + assert base_year_weights(entry, years) == weights + + +def test_base_year_weights_refuse_unknown_labels(): + with pytest.raises(ValueError, match="unknown entry type"): + base_year_weights("New", ("BY3",)) + with pytest.raises(ValueError, match="unknown base years"): + base_year_weights("Renewing", ("BY4",)) + + +def test_table_2_6_1_base_year_minimums(): + assert base_year_alignment_minimum("standard", 2027) == 3_000 + assert base_year_alignment_minimum("newly_entering", 2029) == 1_800 + assert base_year_alignment_minimum("high_needs_esrd", 2030) == 825 + assert base_year_alignment_minimum("high_needs_esrd", 2036) == 1_000 + assert base_year_is_sufficient(600, "newly_entering", 2027) + assert not base_year_is_sufficient(599, "newly_entering", 2027) + + +# ── §3.2.5 voluntary benchmarks ─────────────────────────────────────────── + + +@pytest.mark.parametrize( + "voluntary,expected", + [ + (1_150.0, (1_100.0, CAPPED_AT_UPPER)), + (850.0, (900.0, RAISED_TO_LOWER)), + (1_050.0, (1_050.0, WITHIN_GUARDRAIL)), + ], +) +def test_voluntary_guardrail_example(voluntary, expected): + benchmark, status = voluntary_guardrail(voluntary, 1_000.0) + assert (round(benchmark, 6), status) == expected + + +def test_voluntary_contribution_and_minimum(): + assert contributes_to_voluntary_benchmark("A&D", None) + assert contributes_to_voluntary_benchmark("High Needs", "High Needs") + assert not contributes_to_voluntary_benchmark("High Needs", "A&D") + assert not contributes_to_voluntary_benchmark("High Needs", None) + assert voluntary_benchmark_minimum_met(500) + assert not voluntary_benchmark_minimum_met(499) + + +# ── §3.4.1–3.4.2 regional FFS and spending designation ──────────────────── + + +def test_regional_ffs_expenditure_weights_risk_standardized_counties(): + # County A: $1,100 / 1.10 = $1,000 at 75%; county B: $1,200 / 1.00 at 25%. + assert regional_ffs_expenditure( + [(1_100.0, 1.10, 750.0), (1_200.0, 1.0, 250.0)] + ) == pytest.approx(1_050.0) + assert regional_ffs_expenditure([(1_100.0, None, 750.0)]) is None + assert regional_ffs_expenditure([(1_100.0, 1.1, 0.0)]) is None + + +def test_table_3_4_2_spending_designation(): + rows = [(r[0], r[1], r[2]) for r in REA_TABLES.values()] + result = spending_designation(rows) + assert result.weighted_difference == pytest.approx(TABLE_3_4_2_DIFFERENCE) + assert result.designation == "Lower-spending" + assert ( + spending_designation([(900.0, 1_000.0, 1.0)]).designation == "Higher-spending" + ) + # The paper names no tie. + assert spending_designation([(1_000.0, 1_000.0, 1.0)]).designation is None + with pytest.raises(ValueError, match="BY 3 aligned months"): + spending_designation([(1_000.0, 900.0, 0.0)]) + + +# ── §3.4.3 Regional Efficiency Adjustment ───────────────────────────────── + + +@pytest.mark.parametrize("category", list(REA_TABLES)) +def test_tables_3_4_3_1_to_3_4_3_4(category): + regional, historical, _, uncapped, uspcc, risk, cap, applied, adjusted = REA_TABLES[ + category + ] + rea = regional_efficiency_adjustment( + regional, + historical, + eligible=regional_efficiency_eligible("Lower-spending", "Global"), + uspcc_amount=uspcc, + py_average_risk_score=risk, + cap_rate=benchmark_adjustment_cap_rate(reduced=False), + ) + assert rea.uncapped == uncapped + assert cents(rea.cap) == cap + assert cents(rea.applied) == applied + assert cents(historical + rea.applied) == adjusted + + +def test_rea_eligibility_and_missing_cap_inputs(): + assert not regional_efficiency_eligible("Higher-spending", "Global") + assert not regional_efficiency_eligible("Lower-spending", "Professional") + kwargs = dict(uspcc_amount=None, py_average_risk_score=1.0, cap_rate=0.05) + # A positive REA never falls back to its uncapped amount. + assert ( + regional_efficiency_adjustment(1_050, 1_000, eligible=True, **kwargs).applied + is None + ) + assert ( + regional_efficiency_adjustment(950, 1_000, eligible=True, **kwargs).applied + == 0.0 + ) + assert ( + regional_efficiency_adjustment(1_050, 1_000, eligible=False, **kwargs).applied + == 0.0 + ) + + +def test_reduced_cap_rate(): + assert benchmark_adjustment_cap_rate(reduced=True) == 0.03 + assert benchmark_adjustment_cap(1_000, 1.02, 0.03) == pytest.approx(30.6) + + +def test_table_3_4_3_6_weighted_rea_with_an_excluded_tin(): + rea = regional_efficiency_adjustment( + 3_700, + 3_000, + eligible=True, + uspcc_amount=3_500, + py_average_risk_score=1.03, + cap_rate=0.05, + ) + assert (rea.difference, rea.uncapped, cents(rea.applied)) == (700, 350, 180.25) + assert cents(month_weighted_average([(rea.applied, 8_000), (0.0, 2_000)])) == 144.20 + + +# ── §3.4.4 Prior Savings Adjustment ─────────────────────────────────────── + + +def test_table_3_4_3_7_and_example_1(): + rows = [ + (120_000_000, 114_250_000, 115_000), + (125_000_000, 126_200_000, 120_000), + (130_000_000, 121_250_000, 125_000), + ] + pbpm = [prior_savings_pbpm(*row) for row in rows] + assert pbpm == [50.0, -10.0, 70.0] + average = sum(pbpm) / 3 + assert cents(average) == 36.67 + psa = prior_savings_adjustment(average, 120_000, 240_000, cap=None) + assert psa.eligible and psa.proration_factor == 0.5 + assert cents(psa.prorated_average_savings) == 18.33 + assert cents(psa.uncapped) == 9.17 + assert psa.applied is None # eligible, but no cap supplied + + +def test_table_3_4_3_8_9_examples_2_and_3(): + average = month_weighted_average([(30.0, 80_000), (70.0, 20_000)]) + assert average == pytest.approx(38.0) + cap = prior_savings_cap( + [(0.80, 1_000, 1.02), (0.05, 8_000, 0.99), (0.15, 3_500, 1.03)], cap_rate=0.05 + ) + assert cents(cap) == 87.64 + psa = prior_savings_adjustment(average, 100_000, 125_000, cap) + assert psa.proration_factor == 0.8 + assert cents(psa.prorated_average_savings) == 30.40 + assert cents(psa.applied) == 15.20 + + +def test_prior_savings_eligibility_and_proration_cap(): + not_eligible = prior_savings_adjustment(-5.0, 100_000, 100_000, cap=50.0) + assert (not_eligible.eligible, not_eligible.applied) == (False, 0.0) + shrank = prior_savings_adjustment(40.0, 200_000, 100_000, cap=5.0) + assert shrank.proration_factor == 1.0 + assert shrank.applied == 5.0 # capped + with pytest.raises(ValueError, match="PY beneficiary months"): + prior_savings_adjustment(40.0, 1, 0, cap=None) + assert prior_savings_pbpm(1.0, 0.0, 0) is None + + +# ── §3.5.1 three-way trend and ACPT ─────────────────────────────────────── + + +@pytest.mark.parametrize( + "year,acpt,applied", + [ + (2027, 1.070, 1.053), # raw 1.0567 capped at two-way + 0.3 points + (2027, 1.020, 1.048), # raw 1.04 raised to two-way − 0.2 points + (2027, 1.055, (2 / 3) * 1.05 + 1.055 / 3), # within the band + (2028, 1.070, 1.056), # PY 2 caps at +0.6 points + (2028, 1.065, 1.055), # within PY 2's wider band + (2032, 0.950, 1.04), # PY 6: −1.0 points + ], +) +def test_three_way_blended_trend_guardrail(year, acpt, applied): + trend = three_way_blended_trend(1.05, acpt, year) + assert trend.applied == pytest.approx(applied) + assert trend.lower_bound <= trend.applied <= trend.upper_bound + + +def test_cumulative_acpt(): + assert cumulative_acpt([0.04, 0.05]) == pytest.approx(1.092) + assert cumulative_acpt([0.04, None]) is None + assert cumulative_acpt([]) is None + + +# ── §3.5.2 risk adjustment and the growth cap ───────────────────────────── + + +def test_section_3_5_2_examples(): + assert performance_year_benchmark_pbpm(1_100.0, 0.0, 1.0, 1.02) == pytest.approx( + 1_122.0 + ) + score, status = capped_risk_score(1.000, 1.050, "A&D") + assert (score, status) == (1.03, CAPPED) + assert performance_year_benchmark_pbpm(1_100.0, 0.0, 1.0, score) == pytest.approx( + 1_133.0 + ) + + +def test_risk_score_cap_by_category(): + assert capped_risk_score(1.0, 0.90, "ESRD") == (0.97, FLOORED) + assert capped_risk_score(1.0, 1.035, "High Needs") == (1.035, WITHIN_CAP) + assert capped_risk_score(1.0, 1.05, "High Needs") == (1.04, CAPPED) + + +def test_missing_adjustment_stays_missing(): + assert performance_year_benchmark_pbpm(1_000.0, None, 1.03, 1.0) is None + + +# ── §3.5.3 hybrid alignment and §3.5.4 combination ──────────────────────── + + +def test_table_3_5_3_2_hybrid_alignment(): + t = TABLE_3_5_3_2 + assert cents(t["1"] * t["4"]) == t["5"] + dollars = benchmark_dollars(t["1"], t["2"], [(t["3"], t["4"])]) + assert (dollars.full_year, dollars.hybrid, dollars.total) == ( + t["6"], + t["7"], + t["8"], + ) + assert hybrid_adjustment_factor(1_009.9, 1_000.0) == pytest.approx(1.0099) + assert hybrid_adjustment_factor(1.0, 0.0) is None + + +@pytest.mark.parametrize("category", list(TABLE_3_5_4_CLAIMS)) +def test_table_3_5_4_claims_line_8(category): + pbpm, trend, risk, full, hybrid, factor, line8 = TABLE_3_5_4_CLAIMS[category] + py_pbpm = performance_year_benchmark_pbpm(pbpm, 0.0, trend, risk) + assert benchmark_dollars(py_pbpm, full, [(hybrid, factor)]).total == line8 + + +@pytest.mark.parametrize("category", list(TABLE_3_5_4_VOLUNTARY)) +def test_table_3_5_4_voluntary_line_22(category): + pbpm, trend, risk, full, months, factors, line22 = TABLE_3_5_4_VOLUNTARY[category] + py_pbpm = performance_year_benchmark_pbpm(pbpm, 0.0, trend, risk) + hybrid = list(zip(months, factors, strict=True)) + assert benchmark_dollars(py_pbpm, full, hybrid).total == line22 + + +def test_table_3_5_4_totals_sum_the_cent_rounded_cells(): + claims = {c: v[-1] for c, v in TABLE_3_5_4_CLAIMS.items()} + voluntary = {c: v[-1] for c, v in TABLE_3_5_4_VOLUNTARY.items()} + assert total_benchmark(claims.values()) == TABLE_3_5_4_TOTALS["8"] + assert total_benchmark(voluntary.values()) == TABLE_3_5_4_TOTALS["22"] + line24 = {c: total_benchmark([claims[c], voluntary[c]]) for c in claims} + for category, expected in TABLE_3_5_4_TOTALS["24"].items(): + if category != "TOTAL": + assert line24[category] == expected + assert total_benchmark(line24.values()) == TABLE_3_5_4_TOTALS["24"]["TOTAL"] + + +def test_table_3_5_4_esrd_line_24_needs_cent_rounded_cells(): + # Unrounded, ESRD line 8 + line 22 = 17,675,378.2467 → .25, not the printed .24. + pbpm, trend, risk, full, hybrid, factor, _ = TABLE_3_5_4_CLAIMS["ESRD"] + claims = pbpm * trend * risk * (full + hybrid * factor) + pbpm, trend, risk, full, months, factors, _ = TABLE_3_5_4_VOLUNTARY["ESRD"] + voluntary = ( + pbpm + * trend + * risk + * (full + sum(m * f for m, f in zip(months, factors, strict=True))) + ) + assert cents(claims + voluntary) == 17_675_378.25 + assert TABLE_3_5_4_TOTALS["24"]["ESRD"] == 17_675_378.24 diff --git a/tests/lead/test_express_benchmark.py b/tests/lead/test_express_benchmark.py new file mode 100644 index 0000000..2e8a135 --- /dev/null +++ b/tests/lead/test_express_benchmark.py @@ -0,0 +1,335 @@ +"""lead.express.benchmark: the §3 worked examples as frames on polars and +DuckDB, plus nulls for missing inputs.""" + +from __future__ import annotations + +import narwhals as nw +import polars as pl +import pytest + +from lead import benchmark as scalar +from lead.express import cents +from lead.express.benchmark import ( + RISK_CAP_REFERENCE_MISSING, + benchmark_adjustment_cap, + benchmark_dollars, + capped_risk_score, + contributes_to_voluntary_benchmark, + county_market_share, + county_weight, + growth_rate, + month_weighted_mean, + performance_year_benchmark_pbpm, + regional_efficiency_adjustment, + regional_efficiency_eligible, + risk_growth_cap_status, + risk_standardized_pbpm, + three_way_blended_trend, + trended_weighted_pbpm, + two_way_blended_trend, + uncapped_regional_efficiency_adjustment, + voluntary_guardrail, + voluntary_guardrail_status, +) +from lead.money import cents as scalar_cents +from tests.lead.paper import ( + REA_TABLES, + TABLE_3_3_3, + TABLE_3_5_4_CLAIMS, + TABLE_3_5_4_TOTALS, + TABLE_3_5_4_VOLUNTARY, +) + + +def test_box_3_3_2_as_a_county_frame(backend): + counties = { + "aligned": [20_000.0, 5_000.0], + "eligible": [100_000.0, 50_000.0], + "total": [25_000.0, 25_000.0], + } + rows = backend.select( + counties, + contribution=county_market_share("aligned", "eligible") + * county_weight("aligned", "total"), + ) + weight = sum(r["contribution"] for r in rows) + assert weight == pytest.approx(0.18) + (trend,) = backend.select( + { + "nat_by3": [1_120.0], + "nat_by1": [1_000.0], + "reg_by3": [1_155.0], + "reg_by1": [1_050.0], + "w": [weight], + }, + trend=two_way_blended_trend( + growth_rate("nat_by3", "nat_by1"), growth_rate("reg_by3", "reg_by1"), "w" + ), + ) + assert round(trend["trend"], 3) == 1.104 + + +def test_growth_and_shares_are_null_without_positive_denominators(backend): + data = {"a": [0.0, 10.0, None], "b": [5.0, 0.0, 5.0]} + rows = backend.select( + data, + {"a": pl.Float64, "b": pl.Float64}, + g=growth_rate("a", "b"), + s=county_market_share("a", "b"), + ) + assert [(r["g"], r["s"]) for r in rows] == [(None, 0.0), (None, None), (None, None)] + + +def test_table_3_3_3_as_a_base_year_frame(backend): + years = ("BY1", "BY2", "BY3") + data = { + "year": list(years), + "paid": [TABLE_3_3_3[y][0] for y in years], + "months": [float(TABLE_3_3_3[y][1]) for y in years], + "risk": [TABLE_3_3_3[y][2] for y in years], + "trend": [TABLE_3_3_3[y][5] for y in years], + "weight": [TABLE_3_3_3[y][7] for y in years], + } + rs = risk_standardized_pbpm("paid", "months", "risk") + rows = backend.select( + data, + year=nw.col("year"), + line5=cents(rs), + line9=cents(trended_weighted_pbpm(rs, "trend", "weight")), + raw=trended_weighted_pbpm(rs, "trend", "weight"), + ) + by_year = {r["year"]: r for r in rows} + for year in years: + assert by_year[year]["line5"] == TABLE_3_3_3[year][4] + assert by_year[year]["line9"] == TABLE_3_3_3[year][8] + assert ( + scalar_cents(sum(r["raw"] for r in rows)) == TABLE_3_3_3["historical_benchmark"] + ) + + +def test_rea_tables_as_a_category_frame(backend): + data = { + "category": list(REA_TABLES), + "regional": [float(v[0]) for v in REA_TABLES.values()], + "historical": [float(v[1]) for v in REA_TABLES.values()], + "uspcc": [float(v[4]) for v in REA_TABLES.values()], + "risk": [v[5] for v in REA_TABLES.values()], + "designation": ["Lower-spending"] * 3, + "option": ["Global"] * 3, + } + rea = regional_efficiency_adjustment( + "regional", + "historical", + eligible=regional_efficiency_eligible("designation", "option"), + uspcc_amount="uspcc", + py_average_risk_score="risk", + cap_rate=0.05, + ) + rows = backend.select( + data, + uncapped=uncapped_regional_efficiency_adjustment("regional", "historical"), + cap=cents(benchmark_adjustment_cap("uspcc", "risk", 0.05)), + applied=cents(rea), + adjusted=cents(nw.col("historical") + rea), + ) + for values, result in zip(REA_TABLES.values(), rows, strict=True): + assert ( + result["uncapped"], + result["cap"], + result["applied"], + result["adjusted"], + ) == (values[3], values[6], values[7], values[8]) + + +def test_rea_nulls_and_zeros(backend): + data = { + "regional": [1_050.0, 1_050.0, 950.0, 1_050.0, None], + "historical": [1_000.0] * 5, + "uspcc": [None, 1_000.0, None, 1_000.0, 1_000.0], + "eligible": [True, None, True, False, True], + } + schema = { + "regional": pl.Float64, + "historical": pl.Float64, + "uspcc": pl.Float64, + "eligible": pl.Boolean, + } + rea = regional_efficiency_adjustment( + "regional", + "historical", + eligible="eligible", + uspcc_amount="uspcc", + py_average_risk_score=nw.lit(1.0), + cap_rate="cap_rate", + ) + frame_rows = backend.select( + {**data, "cap_rate": [0.05] * 5}, {**schema, "cap_rate": pl.Float64}, rea=rea + ) + # missing cap on a positive REA, unknown eligibility, at/above regional, + # ineligible, missing regional + assert [r["rea"] for r in frame_rows] == [None, None, 0.0, 0.0, None] + + +def test_three_way_trend_per_row_performance_year(backend): + data = { + "two_way": [1.05, 1.05, 1.05, 1.05], + "acpt": [1.07, 1.02, 1.07, 1.07], + "py": [2027, 2027, 2028, 2040], + } + rows = backend.select(data, t=three_way_blended_trend("two_way", "acpt", "py")) + expected = [ + scalar.three_way_blended_trend(1.05, a, y).applied + for a, y in ((1.07, 2027), (1.02, 2027), (1.07, 2028)) + ] + assert [r["t"] for r in rows] == [*expected, None] + (literal,) = backend.select( + {"two_way": [1.05], "acpt": [1.07]}, + t=three_way_blended_trend("two_way", "acpt", 2027), + ) + assert literal["t"] == expected[0] + + +def test_capped_risk_score_and_status(backend): + data = { + "by3": [1.0, 1.0, 1.0, None, 1.0], + "py": [1.05, 0.90, 1.035, 1.2, 1.2], + "cat": ["A&D", "ESRD", "High Needs", "A&D", "Other"], + } + rows = backend.select( + data, + {"by3": pl.Float64, "py": pl.Float64, "cat": pl.String}, + score=capped_risk_score("by3", "py", "cat"), + status=risk_growth_cap_status("by3", "py", "cat"), + ) + expected = [ + scalar.capped_risk_score(1.0, py, cat) + for py, cat in ((1.05, "A&D"), (0.90, "ESRD"), (1.035, "High Needs")) + ] + assert [(r["score"], r["status"]) for r in rows[:3]] == expected + # No BY 3 reference or unknown category: no silent pass-through. + assert [(r["score"], r["status"]) for r in rows[3:]] == [ + (None, RISK_CAP_REFERENCE_MISSING) + ] * 2 + + +def test_voluntary_guardrail_and_contribution(backend): + data = { + "vol": [1_150.0, 850.0, 1_050.0, 1_000.0], + "claims": [1_000.0, 1_000.0, 1_000.0, None], + } + rows = backend.select( + data, + {"vol": pl.Float64, "claims": pl.Float64}, + b=voluntary_guardrail("vol", "claims"), + s=voluntary_guardrail_status("vol", "claims"), + ) + expected = [ + scalar.voluntary_guardrail(v, 1_000.0) for v in (1_150.0, 850.0, 1_050.0) + ] + assert [(r["b"], r["s"]) for r in rows[:3]] == expected + assert (rows[3]["b"], rows[3]["s"]) == (None, None) + cats = { + "cat": ["A&D", "High Needs", "High Needs", "High Needs"], + "by3": ["A&D", "High Needs", "A&D", None], + } + flags = backend.select( + cats, + {"cat": pl.String, "by3": pl.String}, + f=contributes_to_voluntary_benchmark("cat", "by3"), + ) + assert [r["f"] for r in flags] == [True, True, False, False] + + +def test_table_3_5_4_as_a_frame(backend): + rows = [] + for category, ( + pbpm, + trend, + risk, + full, + hybrid, + factor, + _, + ) in TABLE_3_5_4_CLAIMS.items(): + rows.append( + { + "category": category, + "pbpm": pbpm, + "trend": trend, + "risk": risk, + "full": float(full), + "m2": float(hybrid), + "f2": factor, + "m3": 0.0, + "f3": 1.0, + "m4": 0.0, + "f4": 1.0, + } + ) + for category, ( + pbpm, + trend, + risk, + full, + months, + factors, + _, + ) in TABLE_3_5_4_VOLUNTARY.items(): + rows.append( + { + "category": category, + "pbpm": pbpm, + "trend": trend, + "risk": risk, + "full": float(full), + **{f"m{q}": float(m) for q, m in zip((2, 3, 4), months, strict=True)}, + **{f"f{q}": f for q, f in zip((2, 3, 4), factors, strict=True)}, + } + ) + py_pbpm = performance_year_benchmark_pbpm("pbpm", nw.lit(0.0), "trend", "risk") + dollars = benchmark_dollars( + py_pbpm, "full", [("m2", "f2"), ("m3", "f3"), ("m4", "f4")] + ) + cells = backend.select(rows, category=nw.col("category"), dollars=dollars) + expected = [v[-1] for v in TABLE_3_5_4_CLAIMS.values()] + [ + v[-1] for v in TABLE_3_5_4_VOLUNTARY.values() + ] + assert [c["dollars"] for c in cells] == expected + line24 = {} + for cell in cells: + line24[cell["category"]] = scalar.total_benchmark( + [line24.get(cell["category"], 0.0), cell["dollars"]] + ) + assert line24 == {k: v for k, v in TABLE_3_5_4_TOTALS["24"].items() if k != "TOTAL"} + + +def test_benchmark_dollars_without_hybrid_months(backend): + (result,) = backend.select( + {"pbpm": [1_125.0], "months": [60_000.0]}, d=benchmark_dollars("pbpm", "months") + ) + assert result["d"] == 67_500_000.0 + + +def test_month_weighted_mean_in_a_group_by(backend): + data = { + "cat": ["A&D", "A&D", "A&D", "ESRD", "HN"], + "score": [1.0, 1.2, None, 0.9, 1.5], + "months": [10.0, 30.0, 50.0, 12.0, 0.0], + } + frame = nw.from_native( + backend.frame( + data, {"cat": pl.String, "score": pl.Float64, "months": pl.Float64} + ) + ) + out = ( + frame.group_by("cat") + .agg(avg=month_weighted_mean("score", "months")) + .sort("cat") + ) + rows = backend.rows(out.to_native()) + assert [r["cat"] for r in rows] == ["A&D", "ESRD", "HN"] + assert rows[0]["avg"] == pytest.approx( + scalar.month_weighted_average([(1.0, 10), (1.2, 30), (None, 50)]) + ) + assert rows[1]["avg"] == pytest.approx(0.9) + assert rows[2]["avg"] is None diff --git a/tests/lead/test_express_settlement.py b/tests/lead/test_express_settlement.py new file mode 100644 index 0000000..4ae5166 --- /dev/null +++ b/tests/lead/test_express_settlement.py @@ -0,0 +1,230 @@ +"""lead.express.settlement settles frames of scenarios on polars and DuckDB: +Table 5.6.1 to the cent, bit-for-bit parity with the scalar engine over a +grid of scenarios, and nulls — not zeros — for missing inputs.""" + +from __future__ import annotations + +import random +from dataclasses import asdict + +import polars as pl +import pytest + +from lead.express import cents +from lead.express.settlement import ( + INPUT_COLUMNS, + LINE_COLUMNS, + discount_amount, + discount_rate, + settle, +) +from lead.money import cents as scalar_cents +from lead.params import DISCOUNT_RATES +from lead.settlement import SettlementInputs +from lead.settlement import settle as settle_scalar +from tests.lead.paper import TABLE_5_6_1, TABLE_5_6_1_INPUTS, TABLE_5_6_1_LINES + +SCHEMA = { + "py_benchmark": pl.Float64, + "risk_option": pl.String, + "spending_designation": pl.String, + "performance_year": pl.Int64, + "quality_score": pl.Float64, + "capitation_payments": pl.Float64, + "participant_provider_claims": pl.Float64, + "preferred_provider_claims": pl.Float64, + "non_aco_provider_claims": pl.Float64, + "stop_loss_elected": pl.Boolean, + "stop_loss_charge": pl.Float64, + "stop_loss_payout": pl.Float64, + "stop_loss_neutrality_factor": pl.Float64, + "retention_incentive_applies": pl.Boolean, +} + + +def row(**changes): + return {**TABLE_5_6_1_INPUTS, "risk_option": "Global", **changes} + + +def settled(backend, rows): + return backend.rows(settle(backend.frame(rows, SCHEMA))) + + +def test_schema_matches_the_input_model(): + assert tuple(SCHEMA) == INPUT_COLUMNS + assert "shared_savings_losses_after_sequestration" in LINE_COLUMNS + + +def test_table_5_6_1_every_line_on_each_backend(backend): + rows = settled( + backend, [row(risk_option="Global"), row(risk_option="Professional")] + ) + for result in rows: + expected = TABLE_5_6_1[result["risk_option"]] + for line, field in TABLE_5_6_1_LINES: + assert result[field] == expected[line], (result["risk_option"], line) + + +def scenario_grid() -> list[dict]: + """Valid scenarios across options, designations, years, quality, + elections, and gross results from deep losses to deep savings.""" + rng = random.Random(2027) + scenarios = [] + for i in range(160): + benchmark = rng.choice([150_000_000.0, 48_250_731.17, 9_999_999.99]) + option = rng.choice(["Global", "Professional"]) + elected = rng.random() < 0.7 + share = rng.choice( + [ + -0.62, + -0.5, + -0.35, + -0.2, + -0.15, + -0.1, + -0.0001, + 0.0, + 0.07, + 0.1, + 0.15, + 0.2, + 0.35, + 0.5, + 0.71, + ] + ) + payout = rng.choice([0.0, 1_234_567.89, 2_900_000.0]) + scenarios.append( + row( + py_benchmark=benchmark, + risk_option=option, + spending_designation=rng.choice( + [ + "Lower-spending", + "Higher-spending", + None if option == "Professional" else "Lower-spending", + ] + ), + performance_year=rng.choice([2027, 2028, 2030, 2031, 2036]), + quality_score=rng.choice([0.0, 0.333, 0.95, 1.0]), + capitation_payments=round(benchmark * rng.uniform(0.0, 0.2), 2), + participant_provider_claims=round(benchmark * rng.uniform(0.0, 0.1), 2), + preferred_provider_claims=round(benchmark * rng.uniform(0.0, 0.2), 2), + non_aco_provider_claims=round(benchmark * (0.5 - share), 2), + stop_loss_elected=elected, + stop_loss_charge=round(benchmark * 0.02, 2) if elected else None, + stop_loss_payout=payout if elected else None, + stop_loss_neutrality_factor=( + rng.choice([0.93, 1.07, None]) + if payout == 0.0 + else rng.choice([0.93, 1.07]) + ) + if elected + else None, + retention_incentive_applies=i % 7 == 0, + ) + ) + return scenarios + + +def test_frame_matches_scalar_bit_for_bit(backend): + scenarios = scenario_grid() + rows = settled(backend, scenarios) + assert len(rows) == len(scenarios) + signs = { + (r["shared_savings_losses"] > 0) - (r["shared_savings_losses"] < 0) + for r in rows + } + assert signs == {1, -1} # savings and losses both exercised + for scenario, result in zip(scenarios, rows, strict=True): + expected = asdict(settle_scalar(SettlementInputs(**scenario))) + assert {k: result[k] for k in expected} == expected + + +def test_other_columns_pass_through(backend): + frame = backend.frame([{**row(), "aco": "A1"}], {**SCHEMA, "aco": pl.String}) + assert backend.rows(settle(frame))[0]["aco"] == "A1" + + +def test_not_elected_rows_get_the_papers_zeros(backend): + (result,) = settled( + backend, + [ + row( + stop_loss_elected=False, + stop_loss_charge=None, + stop_loss_payout=None, + stop_loss_neutrality_factor=None, + ) + ], + ) + assert (result["stop_loss_charge"], result["stop_loss_payout"]) == (0.0, 0.0) + assert result["stop_loss_neutrality_factor"] is None + assert result["net_stop_loss_impact"] == 0.0 + assert result["py_expenditure_after_stop_loss"] == result["py_expenditure"] + + +@pytest.mark.parametrize( + "changes,first_null", + [ + ({"quality_score": None}, "quality_earn_back"), + ({"quality_score": 1.2}, "quality_earn_back"), + ({"spending_designation": None}, "discount_rate"), + ({"spending_designation": "lower-spending"}, "discount_rate"), + ({"performance_year": 2037}, "discount_rate"), + ({"risk_option": "global"}, "discount_rate"), + ({"stop_loss_charge": None}, "net_stop_loss_impact"), + ({"stop_loss_neutrality_factor": None}, "adjusted_stop_loss_payout"), + ({"stop_loss_elected": None}, "adjusted_stop_loss_payout"), + ({"retention_incentive_applies": None}, "retention_incentive_adjustment"), + ({"non_aco_provider_claims": None}, "total_ffs_payments"), + ], +) +def test_missing_inputs_null_the_chain_never_zero_it(backend, changes, first_null): + (result,) = settled(backend, [row(**changes)]) + assert result[first_null] is None + assert result["shared_savings_losses_after_sequestration"] is None + assert result["sequestration_amount"] is None + + +def test_missing_columns_are_named(backend): + frame = backend.frame([{"py_benchmark": 1.0}]) + with pytest.raises(ValueError, match="quality_score"): + settle(frame) + + +def test_discount_rate_lookup(backend): + data = [ + {"o": "Global", "d": d, "y": y} + for d, by_year in DISCOUNT_RATES.items() + for y in by_year + ] + [ + {"o": "Professional", "d": None, "y": 2030}, + {"o": "Professional", "d": "Lower-spending", "y": 2026}, + {"o": "Global", "d": "Lower-spending", "y": 2026}, + ] + rates = [r["rate"] for r in backend.select(data, rate=discount_rate("o", "d", "y"))] + assert rates[:-3] == [ + r for by_year in DISCOUNT_RATES.values() for r in by_year.values() + ] + assert rates[-3:] == [0.0, None, None] + + +def test_missing_discount_rate_is_zero_in_the_na_helper(backend): + data = {"benchmark": [150_000_000.0, 150_000_000.0], "rate": [None, 0.03]} + result = backend.select( + data, + {"benchmark": pl.Float64, "rate": pl.Float64}, + amount=discount_amount("benchmark", "rate"), + ) + assert [r["amount"] for r in result] == [0.0, 4_500_000.0] + + +def test_cents_expr_matches_the_scalar_bit_for_bit(backend): + rng = random.Random(5) + values = [rng.uniform(-1e9, 1e9) for _ in range(500)] + values += [k / 1000 for k in range(-2000, 2001, 5)] # half-cent ties + values += [0.125, -0.125, 2.675, 1.005, -0.001, 0.0, None] + result = backend.select({"x": values}, {"x": pl.Float64}, y=cents("x")) + for value, got in zip(values, (r["y"] for r in result), strict=True): + assert got == (None if value is None else scalar_cents(value)), value diff --git a/tests/lead/test_express_stop_loss.py b/tests/lead/test_express_stop_loss.py new file mode 100644 index 0000000..03dce3c --- /dev/null +++ b/tests/lead/test_express_stop_loss.py @@ -0,0 +1,87 @@ +"""lead.express.stop_loss: beneficiary-grain Stop-Loss on polars and DuckDB, +matching Box 5.3.3 and the scalar engine.""" + +from __future__ import annotations + +import random + +import polars as pl + +from lead.express.stop_loss import ( + band_status, + distance_to_attachment_point, + predicted_expenditure, + residual_expenditure, + stop_loss_payout, +) +from lead.stop_loss import band_status as scalar_band_status +from lead.stop_loss import stop_loss_payout as scalar_payout +from tests.lead.paper import STOP_LOSS_PAYOUT + +SCHEMA = {"actual": pl.Float64, "predicted": pl.Float64, "ap": pl.Float64} + + +def test_box_5_3_3_illustration(backend): + data = [ + { + "actual": STOP_LOSS_PAYOUT["actual"], + "predicted": STOP_LOSS_PAYOUT["predicted"], + "ap": STOP_LOSS_PAYOUT["attachment_point"], + } + ] + residual = residual_expenditure("actual", "predicted") + (result,) = backend.select( + data, + SCHEMA, + residual=residual, + payout=stop_loss_payout(residual, "ap"), + status=band_status(residual, "ap"), + distance=distance_to_attachment_point("ap", residual), + ) + assert result == { + "residual": 400_000.0, + "payout": 220_000.0, + "status": "band_2", + "distance": -250_000.0, + } + + +def test_predicted_expenditure(backend): + data = {"regional": [1_000.0], "risk": [1.2], "months": [12.0]} + (result,) = backend.select( + data, predicted=predicted_expenditure("regional", "risk", "months") + ) + assert result["predicted"] == 14_400.0 + + +def test_payout_and_band_match_the_scalar(backend): + rng = random.Random(3) + data = [ + { + "actual": rng.uniform(0, 900_000), + "predicted": rng.uniform(0, 200_000), + "ap": rng.choice([150_000.0, 98_765.43]), + } + for _ in range(200) + ] + data += [ + {"actual": None, "predicted": 1.0, "ap": 150_000.0}, + {"actual": 1.0, "predicted": 0.0, "ap": None}, + ] + residual = residual_expenditure("actual", "predicted") + rows = backend.select( + data, + SCHEMA, + residual=residual, + payout=stop_loss_payout(residual, "ap"), + status=band_status(residual, "ap"), + ) + for inputs, result in zip(data, rows, strict=True): + if inputs["actual"] is None or inputs["ap"] is None: + assert (result["payout"], result["status"]) == (None, None) + continue + assert ( + result["payout"] + == scalar_payout(result["residual"], inputs["ap"]).total_payout + ) + assert result["status"] == scalar_band_status(result["residual"], inputs["ap"]) diff --git a/tests/lead/test_monies_owed.py b/tests/lead/test_monies_owed.py new file mode 100644 index 0000000..d3d2d2c --- /dev/null +++ b/tests/lead/test_monies_owed.py @@ -0,0 +1,71 @@ +"""lead.monies_owed reproduces Table 5.5.1 and keeps who-owes-whom signs.""" + +from __future__ import annotations + +from lead.monies_owed import ( + apo_adjustment, + payment_arrangement_adjustments, + total_monies_owed, +) +from lead.settlement import SettlementInputs, settle +from tests.lead.paper import TABLE_5_5_1, TABLE_5_6_1_INPUTS + + +def test_table_5_5_1_every_line(): + # The example ACO elected TCC: no Enhanced PCC or APO (footnote 1). + arrangements = payment_arrangement_adjustments( + capitation_under_over_payment=TABLE_5_5_1["4.capitation"], + enhanced_pcc_paid=0.0, + apo_adjustment=0.0, + ) + assert arrangements.capitation_under_over_payment == TABLE_5_5_1["4.capitation"] + assert arrangements.payment_arrangement_adjustments == TABLE_5_5_1["4"] + owed = total_monies_owed( + provisional_shared_savings_losses=TABLE_5_5_1["1"], + final_shared_savings_losses=TABLE_5_5_1["2"], + payment_arrangement_adjustments=arrangements.payment_arrangement_adjustments, + high_performers_pool_incentive=TABLE_5_5_1["5"], + ) + assert owed.shared_savings_losses_owed == TABLE_5_5_1["3"] + assert owed.high_performers_pool_incentive == TABLE_5_5_1["5"] + assert owed.adjustments_owed == TABLE_5_5_1["6"] + assert owed.total_monies_owed == TABLE_5_5_1["7"] + + +def test_table_5_5_1_line_2_is_a_cent_below_table_5_6_1_line_28(): + # The paper's own inconsistency: Table 5.5.1 carries the Professional + # final Shared Savings as $8,073,053.43, Table 5.6.1 line 28 as .44 + # (60% × 13,729,682.72 = 8,237,809.632; less 2% = 8,073,053.439). + final = settle(SettlementInputs(**TABLE_5_6_1_INPUTS, risk_option="Professional")) + assert final.shared_savings_losses_after_sequestration == 8_073_053.44 + owed = total_monies_owed( + provisional_shared_savings_losses=TABLE_5_5_1["1"], + final_shared_savings_losses=final.shared_savings_losses_after_sequestration, + payment_arrangement_adjustments=TABLE_5_5_1["4"], + high_performers_pool_incentive=TABLE_5_5_1["5"], + ) + assert owed.total_monies_owed == 1_877_213.44 # Table 5.5.1 line 7: .43 + + +def test_signs_follow_who_owes_whom(): + # Overpaid capitation and the Enhanced PCC recoupment are owed to CMS. + lines = payment_arrangement_adjustments( + capitation_under_over_payment=-5_000.0, + enhanced_pcc_paid=120_000.0, + apo_adjustment=0.0, + ) + assert lines.enhanced_pcc_repayment == -120_000.0 + assert lines.payment_arrangement_adjustments == -125_000.0 + owed = total_monies_owed( + provisional_shared_savings_losses=500_000.0, + final_shared_savings_losses=-200_000.0, + payment_arrangement_adjustments=lines.payment_arrangement_adjustments, + high_performers_pool_incentive=0.0, + ) + assert owed.total_monies_owed == -825_000.0 + + +def test_apo_reconciliation(): + # Reductions above the APO payment are credited; below it, recouped. + assert apo_adjustment(110_000.0, 100_000.0) == 10_000.0 + assert apo_adjustment(90_000.0, 100_000.0) == -10_000.0 diff --git a/tests/lead/test_params_money.py b/tests/lead/test_params_money.py new file mode 100644 index 0000000..e1aaac4 --- /dev/null +++ b/tests/lead/test_params_money.py @@ -0,0 +1,113 @@ +"""lead.params carries the paper's printed parameters; lead.money rounds +dollar lines to the cent half away from zero.""" + +from __future__ import annotations + +import pytest + +from lead.money import cents +from lead.params import ( + ACPT_GUARDRAILS, + BASE_YEAR_ALIGNMENT_MINIMUMS, + BASE_YEAR_WEIGHTS, + DISCOUNT_RATES, + QUALITY_WITHHOLD_RATE, + RETENTION_INCENTIVE_RATE, + RISK_CORRIDORS, + RISK_SCORE_GROWTH_CAPS, + SEQUESTRATION_RATE, + model_year, +) + + +def test_settlement_rates(): + assert (QUALITY_WITHHOLD_RATE, RETENTION_INCENTIVE_RATE, SEQUESTRATION_RATE) == ( + 0.03, + 0.02, + 0.02, + ) + + +def test_tables_5_4_1_and_5_4_3_risk_corridors(): + def table(option, scenario): + return [(c.lower, c.upper, c.rate) for c in RISK_CORRIDORS[(option, scenario)]] + + for scenario in ("savings", "losses"): + assert table("Global", scenario) == [ + (0.0, 0.15, 1.00), + (0.15, 0.35, 0.50), + (0.35, 0.50, 0.25), + (0.50, None, 0.10), + ] + assert table("Professional", "savings")[0] == (0.0, 0.10, 0.60) + assert table("Professional", "losses")[0] == (0.0, 0.10, 0.50) + assert ( + table("Professional", "losses")[1:] + == table("Professional", "savings")[1:] + == [ + (0.10, 0.15, 0.35), + (0.15, 0.20, 0.15), + (0.20, None, 0.05), + ] + ) + + +def test_table_3_5_1_acpt_guardrails(): + assert ACPT_GUARDRAILS[1] == (-0.002, 0.003) + assert ACPT_GUARDRAILS[4] == (-0.008, 0.012) + assert {ACPT_GUARDRAILS[y] for y in range(5, 11)} == {(-0.010, 0.015)} + + +def test_benchmark_parameters(): + assert dict(RISK_SCORE_GROWTH_CAPS) == { + "A&D": 0.03, + "ESRD": 0.03, + "High Needs": 0.04, + } + assert dict(BASE_YEAR_WEIGHTS["Newly Entering"]) == { + "BY1": 0.1, + "BY2": 0.3, + "BY3": 0.6, + } + assert sum(BASE_YEAR_WEIGHTS["Renewing"].values()) == pytest.approx(1.0) + assert BASE_YEAR_ALIGNMENT_MINIMUMS["newly_entering"][:5] == ( + 600, + 1200, + 1800, + 2400, + 3000, + ) + assert BASE_YEAR_ALIGNMENT_MINIMUMS["high_needs_esrd"][9] == 1000 + + +def test_parameters_are_read_only(): + with pytest.raises(TypeError): + DISCOUNT_RATES["Lower-spending"][2027] = 0.0 # type: ignore[index] + + +def test_model_year(): + assert (model_year(2027), model_year(2036)) == (1, 10) + with pytest.raises(ValueError, match="outside the LEAD model"): + model_year(2037) + + +@pytest.mark.parametrize( + "amount,expected", + [ + (184_593.6544, 184_593.65), + (164_756.19264, 164_756.19), + (2_948_334.2768, 2_948_334.28), + (0.125, 0.13), # half away from zero, not half to even + (-0.125, -0.13), + (2.5, 2.5), + (-0.001, 0.0), + (0.0, 0.0), + (1_877_213.43, 1_877_213.43), + ], +) +def test_cents(amount, expected): + assert cents(amount) == expected + + +def test_cents_never_returns_negative_zero(): + assert str(cents(-0.001)) == "0.0" diff --git a/tests/lead/test_settlement.py b/tests/lead/test_settlement.py new file mode 100644 index 0000000..f0f3238 --- /dev/null +++ b/tests/lead/test_settlement.py @@ -0,0 +1,284 @@ +"""lead.settlement reproduces Table 5.6.1 line by line and keeps the +paper's edge cases: no Professional discount, the "N/A" discount, losses, +corridor boundaries, the retention incentive, and Stop-Loss elections.""" + +from __future__ import annotations + +from dataclasses import asdict, replace + +import pytest +from pydantic import ValidationError + +from lead.params import DISCOUNT_RATES, RISK_CORRIDORS +from lead.settlement import ( + SettlementInputs, + adjust_benchmark, + apply_discount, + benchmark_discount_rate, + corridor_exposure, + gross_savings_losses, + py_expenditure, + quality_earn_back, + quality_withhold, + retention_incentive_adjustment, + sequestration_amount, + settle, + shared_savings_losses, +) +from tests.lead.paper import TABLE_5_6_1, TABLE_5_6_1_INPUTS, TABLE_5_6_1_LINES + + +def table_5_6_1(risk_option: str, **changes) -> SettlementInputs: + return SettlementInputs( + **{**TABLE_5_6_1_INPUTS, "risk_option": risk_option, **changes} + ) + + +# ── Table 5.6.1 ─────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("risk_option", ["Global", "Professional"]) +@pytest.mark.parametrize( + "line,field", TABLE_5_6_1_LINES, ids=[f"line {n}" for n, _ in TABLE_5_6_1_LINES] +) +def test_table_5_6_1_every_line_to_the_cent(risk_option, line, field): + lines = asdict(settle(table_5_6_1(risk_option))) + assert lines[field] == TABLE_5_6_1[risk_option][line] + + +def test_table_5_2_2_is_lines_1_to_9_of_table_5_6_1(): + # Table 5.2.2 prints the same benchmark adjustments, reductions positive. + for option, rate in (("Global", 0.03), ("Professional", 0.0)): + adjusted = adjust_benchmark(150_000_000.0, rate, 0.95) + assert adjusted.benchmark_after_adjustments == TABLE_5_6_1[option]["9"] + assert adjusted.net_quality_withhold == 225_000.00 + + +def test_table_5_3_2_py_expenditure(): + expenditure = py_expenditure(10_000_000, 1_003_442, 33_435_084, 91_355_457) + assert (expenditure.total_ffs_payments, expenditure.py_expenditure) == ( + 125_793_983.00, + 135_793_983.00, + ) + + +def test_tables_5_4_2_and_5_4_4_corridor_lines(): + global_ = shared_savings_losses(9_229_682.72, 145_275_000.0, "Global") + professional = shared_savings_losses(13_729_682.72, 149_775_000.0, "Professional") + assert global_.retained_by_corridor == (9_229_682.72, 0.0, 0.0, 0.0) + assert professional.retained_by_corridor == (8_237_809.63, 0.0, 0.0, 0.0) + assert (global_.scenario, professional.scenario) == ("savings", "savings") + + +# ── §5.2 benchmark adjustments ──────────────────────────────────────────── + + +@pytest.mark.parametrize( + "designation,year,rate", + [(d, y, r) for d, by_year in DISCOUNT_RATES.items() for y, r in by_year.items()], +) +def test_table_5_2_1_discount_schedule(designation, year, rate): + assert benchmark_discount_rate("Global", designation, year) == rate + + +def test_discount_schedule_values(): + higher = DISCOUNT_RATES["Higher-spending"] + assert [higher[y] for y in range(2027, 2033)] == [ + 0.0175, + 0.02, + 0.0225, + 0.025, + 0.0275, + 0.03, + ] + assert set(DISCOUNT_RATES["Lower-spending"].values()) == {0.03} + + +def test_professional_option_has_no_discount_whatever_the_designation(): + for designation in ("Lower-spending", "Higher-spending", None): + assert benchmark_discount_rate("Professional", designation, 2031) == 0.0 + + +def test_higher_spending_2027_discount_dollars(): + lines = settle(table_5_6_1("Global", spending_designation="Higher-spending")) + assert (lines.discount_rate, lines.discount_amount) == (0.0175, 2_625_000.00) + assert lines.benchmark_after_adjustments == 147_150_000.00 + + +def test_discount_rate_refuses_what_it_cannot_look_up(): + with pytest.raises(ValueError, match="spending designation"): + benchmark_discount_rate("Global", None, 2027) + with pytest.raises(ValueError, match="outside the LEAD model"): + benchmark_discount_rate("Global", "Lower-spending", 2037) + with pytest.raises(ValueError, match="unknown risk option"): + benchmark_discount_rate("global", "Lower-spending", 2027) + + +def test_missing_discount_rate_is_the_papers_na_and_removes_nothing(): + discount = apply_discount(150_000_000.0, None) + assert (discount.discount_rate, discount.discount_amount) == (0.0, 0.0) + assert discount.benchmark_after_discount == 150_000_000.0 + + +def test_quality_withhold_is_on_the_benchmark_before_the_discount(): + assert quality_withhold(150_000_000.0) == 4_500_000.00 + assert quality_earn_back(4_500_000.0, 0.0) == 0.0 + assert quality_earn_back(4_500_000.0, 1.0) == 4_500_000.0 + with pytest.raises(ValueError, match="quality score"): + quality_earn_back(4_500_000.0, 1.2) + + +def test_retention_incentive_reduces_the_benchmark_by_two_percent(): + assert retention_incentive_adjustment(150_000_000.0, False) == 0.0 + lines = settle(table_5_6_1("Global", retention_incentive_applies=True)) + assert lines.retention_incentive_adjustment == 3_000_000.00 + assert lines.benchmark_after_adjustments == 145_275_000.00 - 3_000_000.00 + + +# ── §5.4 corridors, losses, sequestration ───────────────────────────────── + +B = 100_000_000.0 + + +@pytest.mark.parametrize( + "option,share,expected", + [ + # Global: 100% to 15%, 50% to 35%, 25% to 50%, 10% beyond. + ("Global", 0.15, 15_000_000.00), + ("Global", 0.35, 15_000_000.00 + 10_000_000.00), + ("Global", 0.50, 25_000_000.00 + 3_750_000.00), + ("Global", 0.60, 28_750_000.00 + 1_000_000.00), + # Professional savings: 60% to 10%, 35% to 15%, 15% to 20%, 5% beyond. + ("Professional", 0.10, 6_000_000.00), + ("Professional", 0.15, 6_000_000.00 + 1_750_000.00), + ("Professional", 0.20, 7_750_000.00 + 750_000.00), + ("Professional", 0.30, 8_500_000.00 + 500_000.00), + # Professional losses: corridor 1 shares 50%. + ("Professional", -0.10, -5_000_000.00), + ( + "Professional", + -0.30, + -(5_000_000.00 + 1_750_000.00 + 750_000.00 + 500_000.00), + ), + ("Global", -0.60, -29_750_000.00), + ], +) +def test_corridor_boundaries(option, share, expected): + shared = shared_savings_losses(B * share, B, option) + assert shared.shared_savings_losses == expected + assert shared.scenario == ("savings" if share > 0 else "losses") + assert sum(shared.retained_by_corridor) == pytest.approx(expected) + + +def test_each_corridor_takes_only_its_band(): + shared = shared_savings_losses(B * 0.60, B, "Global") + assert shared.retained_by_corridor == ( + 15_000_000.0, + 10_000_000.0, + 3_750_000.0, + 1_000_000.0, + ) + bands = RISK_CORRIDORS[("Global", "savings")] + assert [corridor_exposure(B * 0.2, B, c.lower, c.upper) for c in bands] == [ + 15_000_000.0, + 5_000_000.0, + 0.0, + 0.0, + ] + + +def test_unknown_risk_option_is_refused(): + with pytest.raises(ValueError, match="unknown risk option"): + shared_savings_losses(1.0, B, "Hybrid") + + +def test_shared_losses_are_not_sequestered(): + assert sequestration_amount(-1_000_000.0) == 0.0 + assert sequestration_amount(0.0) == 0.0 + assert sequestration_amount(9_229_682.72) == 184_593.65 + + +def test_loss_scenario_end_to_end(): + # Expenditure 20 percent above a Global benchmark after adjustments. + lines = settle( + table_5_6_1( + "Global", + non_aco_provider_claims=91_355_457.00 + + 0.20 * 145_275_000.00 + + 9_229_682.72, + ) + ) + assert lines.gross_savings_losses == -29_055_000.00 + # 15% at 100% (21,791,250) + 5% at 50% (3,631,875). + assert lines.retained_corridor_1 == -21_791_250.00 + assert lines.retained_corridor_2 == -3_631_875.00 + assert lines.shared_savings_losses == -25_423_125.00 + assert lines.sequestration_amount == 0.0 + assert lines.shared_savings_losses_after_sequestration == -25_423_125.00 + + +def test_break_even_shares_nothing(): + assert gross_savings_losses(100.0, 100.0) == 0.0 + shared = shared_savings_losses(0.0, B, "Professional") + assert (shared.scenario, shared.shared_savings_losses) == ("savings", 0.0) + + +# ── Stop-Loss elections and input completeness ─────────────────────────── + + +def test_no_stop_loss_election_leaves_expenditure_unchanged(): + lines = settle( + table_5_6_1( + "Global", + stop_loss_elected=False, + stop_loss_charge=None, + stop_loss_payout=None, + stop_loss_neutrality_factor=None, + ) + ) + assert (lines.stop_loss_charge, lines.adjusted_stop_loss_payout) == (0.0, 0.0) + assert lines.stop_loss_neutrality_factor is None + assert lines.net_stop_loss_impact == 0.0 + assert ( + lines.py_expenditure_after_stop_loss == lines.py_expenditure == 135_793_983.00 + ) + assert lines.gross_savings_losses == 145_275_000.00 - 135_793_983.00 + + +def test_zero_payout_needs_no_neutrality_factor(): + lines = settle( + table_5_6_1("Global", stop_loss_payout=0.0, stop_loss_neutrality_factor=None) + ) + assert lines.adjusted_stop_loss_payout == 0.0 + assert lines.net_stop_loss_impact == 2_948_334.28 + + +@pytest.mark.parametrize( + "changes,message", + [ + ({"spending_designation": None}, "spending designation"), + ({"performance_year": 2026}, "outside the LEAD model"), + ({"stop_loss_charge": None}, "charge and payout are required"), + ({"stop_loss_neutrality_factor": None}, "needs the neutrality factor"), + ({"stop_loss_elected": False}, "not elected"), + ({"quality_score": 1.5}, "less than or equal to 1"), + ], +) +def test_incomplete_scenarios_are_refused(changes, message): + with pytest.raises(ValidationError, match=message): + table_5_6_1("Global", **changes) + + +def test_professional_needs_no_designation(): + lines = settle(table_5_6_1("Professional", spending_designation=None)) + assert ( + lines.shared_savings_losses_after_sequestration + == TABLE_5_6_1["Professional"]["28"] + ) + + +def test_settlement_lines_are_frozen(): + lines = settle(table_5_6_1("Global")) + with pytest.raises(AttributeError): + lines.py_benchmark = 0.0 # type: ignore[misc] + assert replace(lines, py_benchmark=1.0).py_benchmark == 1.0 diff --git a/tests/lead/test_stop_loss.py b/tests/lead/test_stop_loss.py new file mode 100644 index 0000000..669e273 --- /dev/null +++ b/tests/lead/test_stop_loss.py @@ -0,0 +1,134 @@ +"""lead.stop_loss reproduces Box 5.3.3: the charge calculation, the payout +illustration, and the application of Stop-Loss to PY expenditure.""" + +from __future__ import annotations + +import pytest + +from lead.money import cents +from lead.params import STOP_LOSS_ATTACHMENT_PAYOUT_SHARE, STOP_LOSS_BANDS +from lead.stop_loss import ( + adjusted_payout, + apply_stop_loss, + average_payout_percentage, + band_status, + charge_pbpm, + neutrality_factor, + payout_percentage, + predicted_expenditure, + reference_years, + residual_expenditure, + stop_loss_charge, + stop_loss_payout, + trended_attachment_point, + without_stop_loss, +) +from tests.lead.paper import STOP_LOSS_CHARGE, STOP_LOSS_PAYOUT + + +def test_box_5_3_3_charge_calculation(): + pct = average_payout_percentage(STOP_LOSS_CHARGE["RY"]) + charge = stop_loss_charge( + STOP_LOSS_CHARGE["1"], STOP_LOSS_CHARGE["2"], STOP_LOSS_CHARGE["3"], pct + ) + assert charge.trended_risk_adjusted_reference_expenditure == STOP_LOSS_CHARGE["4"] + assert round(charge.average_payout_percentage, 4) == STOP_LOSS_CHARGE["5"] + assert charge.stop_loss_charge == STOP_LOSS_CHARGE["6"] + + +def test_box_5_3_3_charge_uses_the_unrounded_average_payout_percentage(): + # Line 5 prints 2.03%; multiplying line 4 by the printed rate misses line 6. + assert cents(STOP_LOSS_CHARGE["4"] * STOP_LOSS_CHARGE["5"]) == 2_943_500.94 + assert cents(STOP_LOSS_CHARGE["4"] * STOP_LOSS_CHARGE["5"]) != STOP_LOSS_CHARGE["6"] + + +def test_box_5_3_3_payout_illustration(): + residual = residual_expenditure( + STOP_LOSS_PAYOUT["actual"], STOP_LOSS_PAYOUT["predicted"] + ) + assert residual == STOP_LOSS_PAYOUT["residual"] + payout = stop_loss_payout(residual, STOP_LOSS_PAYOUT["attachment_point"]) + band_1, band_2 = payout.bands + assert payout.below_attachment_point == STOP_LOSS_PAYOUT["below_attachment_point"] + assert (band_1.exposure, band_2.exposure) == ( + STOP_LOSS_PAYOUT["band_1_exposure"], + STOP_LOSS_PAYOUT["band_2_exposure"], + ) + assert (band_1.payout, band_2.payout) == ( + STOP_LOSS_PAYOUT["band_1_payout"], + STOP_LOSS_PAYOUT["band_2_payout"], + ) + assert payout.total_payout == STOP_LOSS_PAYOUT["total_payout"] + + +@pytest.mark.parametrize( + "residual,status,total", + [ + (-5_000.0, "below_attachment_point", 0.0), + (150_000.0, "below_attachment_point", 0.0), + (150_000.01, "band_1", 0.01), + (300_000.0, "band_1", 120_000.0), + (300_000.01, "band_2", 120_000.01), + ], +) +def test_band_boundaries(residual, status, total): + assert band_status(residual, 150_000.0) == status + assert stop_loss_payout(residual, 150_000.0).total_payout == total + + +def test_negative_residual_is_nothing_below_the_attachment_point(): + assert stop_loss_payout(-5_000.0, 150_000.0).below_attachment_point == 0.0 + + +def test_payment_schedule_and_attachment_share(): + assert [ + (b.start_multiple, b.end_multiple, b.payout_rate) for b in STOP_LOSS_BANDS + ] == [ + (1.0, 2.0, 0.80), + (2.0, None, 1.00), + ] + assert STOP_LOSS_ATTACHMENT_PAYOUT_SHARE == 0.02 + + +def test_reference_years_leave_a_one_year_gap(): + assert reference_years(2027) == (2023, 2024, 2025) + assert reference_years(2030) == (2026, 2027, 2028) + + +def test_predicted_expenditure_and_trended_attachment_point(): + assert predicted_expenditure(1_000.0, 1.2, 12) == 14_400.00 + assert trended_attachment_point(150_000.0, 1.05) == 157_500.00 + + +def test_payout_percentages(): + assert payout_percentage(196.0, 10_000.0) == 0.0196 + assert payout_percentage(1.0, 0.0) is None + assert average_payout_percentage([0.02, None, 0.03]) is None + assert average_payout_percentage([]) is None + + +def test_charge_pbpm_and_neutrality_factor(): + assert charge_pbpm(2_948_334.28, 132_000) == pytest.approx(22.3359, abs=1e-4) + assert charge_pbpm(1.0, 0) is None + assert neutrality_factor(93.0, 100.0) == 0.93 + # No payouts model-wide: the ratio is undefined, not 1.0. + assert neutrality_factor(93.0, 0.0) is None + + +def test_application_of_stop_loss_lines_16_to_22(): + applied = apply_stop_loss(135_793_983.00, 2_948_334.28, 2_900_000.00, 0.930) + assert applied.adjusted_stop_loss_payout == 2_697_000.00 + assert applied.net_stop_loss_impact == 251_334.28 + assert applied.py_expenditure_after_stop_loss == 136_045_317.28 + + +def test_adjusted_payout_without_a_factor(): + assert adjusted_payout(0.0, None) == 0.0 + with pytest.raises(ValueError, match="neutrality factor"): + adjusted_payout(10.0, None) + + +def test_without_stop_loss(): + applied = without_stop_loss(100.0) + assert applied.py_expenditure_after_stop_loss == 100.0 + assert applied.stop_loss_neutrality_factor is None diff --git a/tests/lead/test_timing.py b/tests/lead/test_timing.py new file mode 100644 index 0000000..83d9639 --- /dev/null +++ b/tests/lead/test_timing.py @@ -0,0 +1,100 @@ +"""lead.timing: the run-out, the settlement basis rule and settlement-stage +inputs.""" + +from __future__ import annotations + +from datetime import date + +import pytest + +from lead.timing import ( + ACTUAL_CLAIMS_BASIS, + CMS_SETTLEMENT_REPORT, + CMS_SETTLEMENT_REPORT_MISSING, + COMPUTED_STATUS, + FINAL_NOT_YET_MADE, + PARTIAL_YEAR_STATUS, + PROJECTED_BASIS, + PROJECTED_STATUS, + PROVISIONAL_NOT_YET_MADE, + SettlementBasis, + require_projection_allowed, + settlement_basis_for_run, + settlement_runout_end, + settlement_stage_input, + validate_run_mode, +) + + +def test_runout_ends_march_31_of_the_following_year(): + assert settlement_runout_end(2027) == date(2028, 3, 31) + + +def test_before_the_py_a_projection_is_projected(): + basis = settlement_basis_for_run("projection", date(2026, 10, 20), 2027, 0) + assert basis == SettlementBasis( + PROJECTED_BASIS, PROJECTED_STATUS, PROJECTED_STATUS, date(2026, 10, 20) + ) + + +def test_inside_the_py_rows_are_partial_actuals_under_a_projected_headline(): + basis = settlement_basis_for_run("projection", date(2027, 6, 1), 2027, 10) + assert (basis.basis, basis.headline_status, basis.actual_rows_status) == ( + PROJECTED_BASIS, + PROJECTED_STATUS, + PARTIAL_YEAR_STATUS, + ) + + +def test_after_the_runout_rows_are_actual(): + for mode in ("projection", "actual"): + basis = settlement_basis_for_run(mode, date(2028, 4, 1), 2027, 10) + assert basis.basis == ACTUAL_CLAIMS_BASIS + assert basis.headline_status == basis.actual_rows_status == COMPUTED_STATUS + + +def test_after_the_runout_without_rows_is_still_a_projection(): + assert ( + settlement_basis_for_run("rewind_test", date(2028, 4, 1), 2027, 0).basis + == PROJECTED_BASIS + ) + + +def test_rows_before_the_py_are_a_feed_error(): + with pytest.raises(ValueError, match="data from the future"): + settlement_basis_for_run("projection", date(2026, 10, 20), 2027, 3) + + +def test_actual_mode_before_the_runout_fails_naming_the_input(): + for as_of, rows in ((date(2026, 10, 20), 0), (date(2027, 6, 1), 10)): + with pytest.raises( + ValueError, match="performance_year_claims_by_provider_class" + ): + settlement_basis_for_run("actual", as_of, 2027, rows) + + +def test_run_modes_are_validated(): + assert validate_run_mode("rewind_test") == "rewind_test" + with pytest.raises(ValueError, match="run_mode must be one of"): + validate_run_mode("Projection") + require_projection_allowed("projection", "x", "y") + + +def test_settlement_stage_inputs_follow_the_as_of_date(): + start = date(2028, 1, 1) + assert settlement_stage_input( + date(2027, 12, 31), start, None, PROVISIONAL_NOT_YET_MADE + ) == ( + 0.0, + PROVISIONAL_NOT_YET_MADE, + ) + assert settlement_stage_input(start, start, None, FINAL_NOT_YET_MADE) == ( + None, + CMS_SETTLEMENT_REPORT_MISSING, + ) + assert settlement_stage_input( + start, start, 6_456_540, PROVISIONAL_NOT_YET_MADE + ) == ( + 6_456_540.0, + CMS_SETTLEMENT_REPORT, + ) diff --git a/uv.lock b/uv.lock index 89ac810..2e4dca7 100644 --- a/uv.lock +++ b/uv.lock @@ -14,7 +14,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-10-20T16:53:32.422989299Z" +exclude-newer = "2026-10-20T17:26:12.492023543Z" exclude-newer-span = "P7D" [[package]] @@ -4660,6 +4660,12 @@ lake = [ { name = "pydantic" }, { name = "sqlglot" }, ] +lead = [ + { name = "duckdb" }, + { name = "httpx" }, + { name = "narwhals" }, + { name = "pydantic" }, +] llm = [ { name = "duckdb" }, { name = "fastapi" }, @@ -4809,6 +4815,7 @@ requires-dist = [ { name = "narwhals", marker = "extra == 'aco'", specifier = ">=2.17.0" }, { name = "narwhals", marker = "extra == 'bcda'", specifier = ">=2.17.0" }, { name = "narwhals", marker = "extra == 'cms'", specifier = ">=2.17.0" }, + { name = "narwhals", marker = "extra == 'lead'", specifier = ">=2.17.0" }, { name = "narwhals", marker = "extra == 'opps'", specifier = ">=2.17.0" }, { name = "narwhals", marker = "extra == 'pfs'", specifier = ">=2.17.0" }, { name = "narwhals", marker = "extra == 'rec'", specifier = ">=2.17.0" }, @@ -4862,6 +4869,7 @@ requires-dist = [ { name = "stack", extras = ["conf"], marker = "extra == 'api'" }, { name = "stack", extras = ["conf"], marker = "extra == 'bcda'" }, { name = "stack", extras = ["conf"], marker = "extra == 'bib'" }, + { name = "stack", extras = ["conf"], marker = "extra == 'lead'" }, { name = "stack", extras = ["conf"], marker = "extra == 'llm'" }, { name = "stack", extras = ["conf"], marker = "extra == 'nlm'" }, { name = "stack", extras = ["conf"], marker = "extra == 'opps'" }, @@ -4872,6 +4880,7 @@ requires-dist = [ { name = "stack", extras = ["conf"], marker = "extra == 'rec'" }, { name = "stack", extras = ["conf"], marker = "extra == 'rex'" }, { name = "stack", extras = ["lake"], marker = "extra == 'all'" }, + { name = "stack", extras = ["lead"], marker = "extra == 'all'" }, { name = "stack", extras = ["llm"], marker = "extra == 'all'" }, { name = "stack", extras = ["llm"], marker = "extra == 'cli'" }, { name = "stack", extras = ["mail"], marker = "extra == 'cli'" }, @@ -4893,7 +4902,7 @@ requires-dist = [ { name = "uvicorn", marker = "extra == 'llm'", specifier = ">=0.41.0" }, { name = "xlrd", specifier = ">=2.0.2" }, ] -provides-extras = ["conf", "aco", "api", "bcda", "bib", "bls", "llm", "ccw", "cli", "nlm", "mail", "cms", "opps", "pfs", "qpp", "rec", "rex", "prisma", "perf", "sem", "lake", "aws", "gcp", "azure", "all"] +provides-extras = ["conf", "aco", "api", "bcda", "bib", "bls", "llm", "ccw", "lead", "cli", "nlm", "mail", "cms", "opps", "pfs", "qpp", "rec", "rex", "prisma", "perf", "sem", "lake", "aws", "gcp", "azure", "all"] [package.metadata.requires-dev] dev = [