From e330f90cdc19a2b7fad7e58cd466b6535c06bce7 Mon Sep 17 00:00:00 2001 From: kert Date: Thu, 12 Nov 2026 19:31:15 -0500 Subject: [PATCH] fix(pfs): member-code new payment comes from the exposure calendar, not the payment window (R84c) new_codes_in judges a family member code new in Y only when pfs.code_exposure has a becomes-payable row for it in Y; link targets stay new only at their link since (R84a). The round-3 row-count heuristic and the payability scan over ValuationRows are gone, so a truncated valuation(years=N) window can neither invent nor hide a new code. money_by_specialty takes exposures=; read_becomes_payable reads them for a family's member codes. --- src/pfs/stakes.py | 100 +++++++++++++++--------------- tests/pfs/test_stakes.py | 129 +++++++++++++++++++++++++++++++-------- 2 files changed, 154 insertions(+), 75 deletions(-) diff --git a/src/pfs/stakes.py b/src/pfs/stakes.py index 7da43bb..30eddb4 100644 --- a/src/pfs/stakes.py +++ b/src/pfs/stakes.py @@ -9,7 +9,10 @@ revalued, in two public series. ``pfs.specialty_impact``. * ``money_by_specialty`` joins a family's payment change per code (from ``pfs.valuation``) with each specialty's billing of those codes (PSPS, - ``pfs.psps``) into a dollar delta per specialty. + ``pfs.psps``) into a dollar delta per specialty. Whether the family gained + new payment comes from the exposure calendar (``pfs.code_exposure``, + ``read_becomes_payable``) and the curated links, never from the payment + rows (R84c). Percents are stored as fractions (``-2%`` -> ``-0.02``). A bare integer in the Federal Register tables is a percent point, so ``1`` -> ``0.01``. @@ -24,13 +27,15 @@ from pathlib import Path from typing import Any, Sequence from pfs.codetables import ( + ExposureRow, ImpactRow, PspsRow, + read_exposures, read_specialty_impacts, write_specialty_impacts, ) from pfs.commenters import specialty_key -from pfs.valuation import PAID_STATUSES, ValuationRow +from pfs.valuation import ValuationRow __all__ = [ "ImpactRow", @@ -39,6 +44,7 @@ __all__ = [ "money_by_specialty", "parse_impact_508", "parse_impact_fr_text", + "read_becomes_payable", "read_impacts", "write_impacts", ] @@ -316,50 +322,44 @@ def billing_share(psps: Sequence[PspsRow], *, year: int) -> dict[str, float]: return {k: v / whole for k, v in totals.items()} +def read_becomes_payable(con: Any, codes: Sequence[str]) -> list[ExposureRow]: + """The exposure calendar's ``becomes-payable`` rows (``pfs.code_exposure``, + #693) for *codes* — what ``money_by_specialty`` judges a member code's + newness on (R84c). Pass the family's member codes.""" + return [ + r + for code in sorted({c.upper() for c in codes}) + for r in read_exposures(con, code=code) + if r.kind == "becomes-payable" + ] + + +def _link_target(link: Any) -> str: + return getattr(link, "target", None) or link.code + + def new_codes_in( - payment: Sequence[ValuationRow], links: Sequence[Any], *, year: int + exposures: Sequence[Any], links: Sequence[Any], *, year: int ) -> tuple[str, ...]: - """The family's new payment events in *year* (R84): a code payable in - *year* (status A/R/T) that was not payable, or absent, in year-1 (judged - only when year-1 is in *payment*), and every curated link with - ``since == year``. A link target is never judged by payability (R84a). A year-1 before the - first supplied year is unknown, and so is a code whose row count equals the - longest history (a ``valuation(years=N)`` window cut) while starting after - the family's first year: neither is flagged (R84b).""" - payable = { - (v.code, v.year) - for v in payment - if not v.proposed and v.status.strip().upper() in PAID_STATUSES - } - by_code: dict[str, set[int]] = {} - for v in payment: - if not v.proposed: - by_code.setdefault(v.code, set()).add(v.year) - first = min((min(ys) for ys in by_code.values()), default=year) - longest = max((len(ys) for ys in by_code.values()), default=0) + """The family's new payment events in *year* (R84): every member code + with a ``becomes-payable`` exposure row in *year*, and every curated + link with ``since == year``. - def truncated(code: str) -> bool: - # ``valuation(years=N)`` keeps the last N vintages per code, so a code - # with N rows that starts after the family's first year may have lost - # its earlier rows: its year-1 is unknown, never "absent" (R84b) - ys = by_code.get(code, set()) - return bool(ys) and min(ys) > first and len(ys) == longest - - targets = {getattr(link, "target", None) or link.code for link in links} - # R84a: payability counts only for the family's own member codes; a link - # target is new only in its link ``since`` year (G2211 became payable for - # office visits in 2024 but joins the home-visit family in 2026) + Member-code newness is the reviewed exposure calendar's (R84c, + ``pfs.exposure``: payable = status A/R/T, left-censored at the first + RVU file, a first-year row only with FR birth evidence), never the + supplied payment rows, so a truncated ``valuation(years=N)`` window can + neither invent nor hide a new code. A link target is new only in its + link ``since`` year, whatever the calendar says (R84a: G2211 became + payable for office visits in 2024 but joins the home-visit family in + 2026).""" + targets = {_link_target(link) for link in links} found = { - c - for c, y in payable - if y == year and year - 1 >= first and c not in targets and not truncated(c) - } - found -= {c for c, y in payable if y == year - 1} - found |= { - getattr(link, "target", None) or link.code - for link in links - if link.since == year + e.code + for e in exposures + if e.kind == "becomes-payable" and e.year == year and e.code not in targets } + found |= {_link_target(link) for link in links if link.since == year} return tuple(sorted(found)) @@ -369,6 +369,7 @@ def money_by_specialty( *, year: int, links: Sequence[Any] = (), + exposures: Sequence[Any] = (), ) -> list[SpecialtyMoney]: """Per specialty (``specialty_key``), the family's payment change in *year*, largest billers first. A code missing either year's final @@ -376,12 +377,15 @@ def money_by_specialty( ``services`` and ``share``. Services are PSPS *submitted* counts, which include denied services. - *links* (``pfs.curated.CuratedLink`` rows: ``target``/``since``; a - ``pfs.code_family`` row's ``code`` works too) add the curated links that - begin in *year*. New payment is a family-level - event (R84): when a code or link begins in *year*, every specialty with - family services in year-1 gets ``new_payment=True``, the codes in - ``new_codes`` and ``delta_note="new, amount n/a"``.""" + *exposures* are the family's member codes' exposure-calendar rows + (``read_becomes_payable``; any ``ExposureRow``-shaped rows, other kinds + ignored) and *links* (``pfs.curated.CuratedLink`` rows: ``target``/ + ``since``; a ``pfs.code_family`` row's ``code`` works too) the curated + links. New payment is a family-level event (R84, ``new_codes_in``): when + a member code becomes payable or a link begins in *year*, every + specialty with family services in year-1 gets ``new_payment=True``, + the codes in ``new_codes`` and ``delta_note="new, amount n/a"``. The + payment rows only price the deltas.""" final: dict[tuple[str, int], float | None] = {} proposed: dict[str, float | None] = {} for v in payment: @@ -391,7 +395,7 @@ def money_by_specialty( else: final[(v.code, v.year)] = v.pay_nf services = _latest_services(psps, year) - new_codes = new_codes_in(payment, links, year=year) + new_codes = new_codes_in(exposures, links, year=year) acc: dict[str, dict[str, Any]] = {} for code, per in services.items(): now, before = final.get((code, year)), final.get((code, year - 1)) diff --git a/tests/pfs/test_stakes.py b/tests/pfs/test_stakes.py index 4d98b61..5a9bbed 100644 --- a/tests/pfs/test_stakes.py +++ b/tests/pfs/test_stakes.py @@ -2,6 +2,7 @@ from __future__ import annotations +import datetime as dt from pathlib import Path import duckdb @@ -10,7 +11,13 @@ import pytest from bib.item import Source from bib.store import Store from pfs import stakes -from pfs.codetables import ImpactRow, PspsRow, ensure_tables +from pfs.codetables import ( + ExposureRow, + ImpactRow, + PspsRow, + ensure_tables, + write_exposures, +) from pfs.stakes import ( billing_share, discover_508, @@ -287,6 +294,28 @@ def ps(year, code, spec, n): return PspsRow(year, code, "00", spec, n, None, None, "d", "K") +def exp(code, year, kind="becomes-payable"): + """An exposure-calendar row (pfs.code_exposure, #693).""" + return ExposureRow( + code=code, + family="", + effective_date=dt.date(year, 1, 1), + year=year, + kind=kind, + status="A", + prior_status="", + rvu_band=0, + non_fac_total=0.0, + delta_pct=0.0, + item_key="", + p_id=0, + page=0, + anchor_kind="", + correction=False, + note="", + ) + + def test_money_by_specialty_exact(): payment = [val("99350", 2022, 100.0), val("99350", 2023, 90.0)] psps = [ @@ -321,13 +350,18 @@ def test_missing_year_gives_none_delta_but_counts_in_share(): ps(2022, "A", "Cardiology", None), # suppressed, skipped ps(2024, "A", "Family Practice", 999.0), # after year-1, ignored ] - by = {m.specialty: m for m in money_by_specialty(payment, psps, year=2023)} + by = { + m.specialty: m + for m in money_by_specialty( + payment, psps, year=2023, exposures=[exp("B", 2023)] + ) + } fp, ca = by["FAMILY PRACTICE"], by["CARDIOLOGY"] assert fp.services == 20.0 and fp.share == pytest.approx(20 / 45) assert fp.delta_total == -500.0 # only code A carries a delta assert fp.delta_per_service == -50.0 # per service of A, not of B assert fp.delta_pct == pytest.approx(-0.5) - assert fp.new_payment is True # B is first payable in 2023 + assert fp.new_payment is True # the calendar: B becomes payable in 2023 assert ca.services == 25.0 assert ( ca.delta_total is None and ca.delta_per_service is None and ca.delta_pct is None @@ -422,7 +456,7 @@ def test_new_code_with_no_psps_rows_flips_new_payment_for_every_specialty(): ps(2022, "99350", "Family Practice", 10.0), ps(2022, "99350", "Cardiology", 5.0), ] - out = money_by_specialty(payment, psps, year=2023) + out = money_by_specialty(payment, psps, year=2023, exposures=[exp("G0318", 2023)]) assert [m.new_payment for m in out] == [True, True] assert all(m.new_codes == ("G0318",) for m in out) assert all(m.delta_note == "new, amount n/a" for m in out) @@ -441,56 +475,97 @@ def test_curated_link_since_year_is_a_new_payment_event(): assert not m.new_payment and m.new_codes == () and m.delta_note == "" -def test_not_new_when_prior_year_payable_or_outside_the_window(): - # B was payable in 2022 (A->now still A): not new; D has no 2022 row at - # all but 2022 is present for other codes: new; E in a window without - # year-1 cannot be judged: not new +def test_payment_rows_alone_never_flag_a_member_code(): + # R84c: D absent in 2022 and X non-payable in 2022 looked "new" in 2023 + # from the payment rows; newness now comes only from the calendar payment = [ val("B", 2022, 5.0), val("B", 2023, 5.0), val("D", 2023, 5.0), val("X", 2022, 5.0, status="B"), - val("X", 2023, 5.0), # not payable in 2022 -> payable 2023: new + val("X", 2023, 5.0), ] psps = [ps(2022, "B", "Family Practice", 1.0)] (m,) = money_by_specialty(payment, psps, year=2023) - assert m.new_codes == ("D", "X") - only23 = [val("E", 2023, 5.0)] - (m,) = money_by_specialty( - only23, [ps(2022, "E", "Family Practice", 1.0)], year=2023 - ) - assert m.new_codes == () and not m.new_payment + assert m.new_codes == () and not m.new_payment and m.delta_note == "" + + +def test_member_code_is_new_in_its_becomes_payable_year_only(): + payment = [val("B", 2022, 5.0), val("B", 2023, 5.0), val("B", 2024, 5.0)] + psps = [ps(2022, "B", "Family Practice", 1.0)] + exposures = [ + exp("X", 2023), + exp("D", 2024), # another year + exp("B", 2023, "revalued"), # other calendar kinds never flag + exp("B", 2023, "status-change"), + exp("E", 2023, "ends"), + exp("T", 2023, "telehealth-listed"), + ] + flags = { + y: money_by_specialty(payment, psps, year=y, exposures=exposures)[0] + for y in (2023, 2024, 2025) + } + assert {y: m.new_codes for y, m in flags.items()} == { + 2023: ("X",), + 2024: ("D",), + 2025: (), + } + assert flags[2023].new_payment and flags[2023].delta_note == "new, amount n/a" + assert not flags[2025].new_payment def test_link_target_flags_only_in_its_since_year_not_when_first_payable(): - # G2211-shaped: payable from 2024, link since 2026 + # G2211-shaped: the calendar has it becoming payable in 2024, link since 2026 payment = [val("99350", y, 100.0) for y in (2023, 2024, 2025, 2026)] payment += [val("G2211", y, 15.0) for y in (2024, 2025, 2026)] psps = [ps(2022, "99350", "Family Practice", 10.0)] links = [Link("G2211", 2026)] + exposures = [exp("G2211", 2024)] flags = { - y: money_by_specialty(payment, psps, year=y, links=links)[0].new_codes + y: money_by_specialty(payment, psps, year=y, links=links, exposures=exposures)[ + 0 + ].new_codes for y in (2024, 2025, 2026) } assert flags == {2024: (), 2025: (), 2026: ("G2211",)} - # a member code (not a link target) still flags by payability - payment.append(val("G0318", 2024, 1.0)) - (m,) = money_by_specialty(payment, psps, year=2024, links=links) + # a member code (not a link target) still flags by its calendar row + exposures.append(exp("G0318", 2024)) + (m,) = money_by_specialty( + payment, psps, year=2024, links=links, exposures=exposures + ) assert m.new_codes == ("G0318",) -def test_truncated_window_flags_nothing_in_its_first_year(): +def test_truncated_payment_window_neither_flags_nor_hides_a_code(): # valuation(years=7) shape: predecessors 2016-2022, current codes cut to - # 2021-2027 (their earlier rows fell out of the window), a real new code + # 2021-2027. NEWC is genuinely new in 2021 with the same row count as the + # longest history (the round-3 heuristic hid it); 99350 is not new payment = [val("99324", y, 50.0) for y in range(2016, 2023)] payment += [val("99350", y, 100.0) for y in range(2021, 2028)] + payment += [val("NEWC", y, 20.0) for y in range(2021, 2028)] payment += [val("G0318", y, 15.0) for y in range(2023, 2028)] psps = [ps(2015, "99350", "Family Practice", 10.0)] + exposures = [exp("NEWC", 2021), exp("G0318", 2023), exp("99324", 2023, "ends")] new = { - y: money_by_specialty(payment, psps, year=y)[0].new_codes + y: money_by_specialty(payment, psps, year=y, exposures=exposures)[0].new_codes for y in range(2016, 2028) } - assert new[2021] == () # 99350's year-1 is outside its window - assert new[2016] == () # before the first supplied year - assert new[2023] == ("G0318",) - assert [y for y, c in new.items() if c] == [2023] + assert {y: c for y, c in new.items() if c} == {2021: ("NEWC",), 2023: ("G0318",)} + + +def test_read_becomes_payable(con): + write_exposures( + con, + [ + exp("G0318", 2023), + exp("G0318", 2023, "telehealth-listed"), + exp("99350", 2027, "telehealth-listed"), + exp("99343", 2023, "ends"), + exp("OTHER", 2020), + ], + ) + got = stakes.read_becomes_payable(con, ["g0318", "99350", "99343"]) + assert [(r.code, r.year, r.kind) for r in got] == [ + ("G0318", 2023, "becomes-payable") + ] + assert stakes.read_becomes_payable(con, []) == []