Files
stack/tests/pfs/test_exposure.py
kert 79ab2ebf47 feat(pfs): exposure calendar — pfs.code_exposure from lineage + RVU status, stack pfs exposure, notebook 7c (refs #693)
One row per (code, effective date, kind) for becomes-payable / revalued /
status-change / ends / telehealth-listed, derived from pfs.rvu (A/R/T
payable, left- and right-censored at the file span) and pfs.code_event
(revalued, telehealth_list), each anchored to the code's nearest
kind-matched FR lineage paragraph. Effective date is 1 January of the
rule year unless the event's own year is anchored only by a correction
notice (bib.frlink.rule_kind), then that notice's publication date.
Rows carry the family key, the year's RVU status and the decile band
of non_fac_total among that year's payable codes; control_codes()
returns the never-treated set (same status + band, no exposure within
a window). stack pfs exposure [--code|--family] [--write]
[--controls N --year Y]; notebook section 7c lists a family's
exposures with FR jump links and a control-set preview.

Live dry run: 6,314 rows / 4,796 codes in 1.3 s; every fixture event
in #693 present (99490 2015, 99487/99489 2017, 99439 + G2058 2021,
99424 + G2064 2022, G2211 2024, G0556 2025, 99441 ends 2025).
2026-09-22 15:45:15 -04:00

383 lines
14 KiB
Python

"""pfs.exposure — the exposure calendar derived from lineage + RVU status (#693)."""
from __future__ import annotations
import datetime as dt
import sqlite3
import duckdb
import pytest
from pfs.codetables import (
EventRow,
ExposureRow,
ensure_tables,
read_exposures,
write_events,
write_exposures,
)
from pfs.exposure import (
KINDS,
build,
control_codes,
derive,
rvu_bands,
rvu_series,
)
RVU_COLS = "hcpcs VARCHAR, mod VARCHAR, description VARCHAR, status_code VARCHAR, non_fac_total DOUBLE, year INTEGER"
def _fr(code, year, kind, item_key, p_id, page=1, from_codes="", to_codes=""):
return EventRow(
code, year, kind, from_codes, to_codes, item_key, p_id, page, "fr", True, ""
)
def _rvu(code, year, kind, note=""):
return EventRow(code, year, kind, "", "", "", 0, 0, "rvu", True, note)
@pytest.fixture
def con():
c = duckdb.connect(":memory:")
ensure_tables(c)
c.execute(f"CREATE TABLE pfs.rvu ({RVU_COLS})")
rows = [
# span 2015..2026; ten filler payable codes give the deciles something to rank
*[
(f"F{i:04d}", None, "filler", "A", float(i), y)
for i in range(1, 11)
for y in range(2015, 2027)
],
# 99490 payable from the first year (left-censored) — FR says created 2015
*[("99490", None, "ccm", "A", 1.2, y) for y in range(2015, 2027)],
# 99487: B 2015-16, A from 2017; +47% revalue 2022
("99487", None, "cplx", "B", 0.0, 2015),
("99487", None, "cplx", "B", 0.0, 2016),
*[("99487", None, "cplx", "A", 2.6, y) for y in range(2017, 2022)],
*[("99487", None, "cplx", "A", 3.9, y) for y in range(2022, 2027)],
# G2058: 2020 only → ends 2021 (replaced by 99439)
("G2058", None, "ccm addl", "A", 1.05, 2020),
*[("99439", "", "ccm addl", "A", 1.1, y) for y in range(2021, 2027)],
# 99441: N through 2020, A 2021-2024, gone 2025
*[("99441", None, "phone", "N", 0.4, y) for y in range(2015, 2021)],
*[("99441", None, "phone", "A", 1.6, y) for y in range(2021, 2025)],
# 12345: A → R (both payable) in 2019, R → C (non-payable) in 2023
*[("12345", None, "x", "A", 5.0, y) for y in range(2015, 2019)],
*[("12345", None, "x", "R", 5.0, y) for y in range(2019, 2023)],
*[("12345", None, "x", "C", 0.0, y) for y in range(2023, 2027)],
# modifier rows are ignored
("99487", "26", "modifier row ignored", "A", 99.0, 2022),
]
c.executemany("INSERT INTO pfs.rvu VALUES (?,?,?,?,?,?)", rows)
write_events(
c,
"99490",
[
_fr("99490", 2015, "adopted_cpt", "DE2VH9PD", 1251, 67716),
_fr("99490", 2015, "created", "6N8FPFHF", 49, 14854),
_fr("99490", 2016, "created", "VZW3HXDA", 1181),
],
)
write_events(
c,
"99487",
[
_rvu("99487", 2017, "status_change", "B→A"),
_rvu("99487", 2022, "revalued", "+47.5%"),
_fr("99487", 2022, "crosswalk", "JE7KYBW3", 900),
],
)
write_events(
c,
"G2058",
[
_rvu("G2058", 2021, "disappeared"),
_fr("G2058", 2021, "replaced_by", "YBM4IZUS", 1578, to_codes="99439"),
],
)
write_events(
c,
"99439",
[
_rvu("99439", 2021, "appeared"),
_fr("99439", 2021, "created", "YBM4IZUS", 686),
_fr("99439", 2021, "replaces", "YBM4IZUS", 1578, from_codes="G2058"),
],
)
write_events(
c,
"99441",
[
_fr("99441", 2022, "telehealth_list", "JE7KYBW3", 555),
_fr("99441", 2023, "telehealth_list", "MZ24MX5S", 336),
_fr("99441", 2023, "telehealth_list", "PETM225D", 458),
_fr("99441", 2025, "deleted", "XFGGRBDH", 489),
_fr("99441", 2025, "bundled", "JJ6AM5HJ", 765),
# a lone correction-only anchor: only a correction notice mentions this
_fr("99441", 2021, "created", "CORR2021", 7),
],
)
write_events(c, "12345", [])
yield c
c.close()
class _Store:
def __init__(self):
self.con = sqlite3.connect(":memory:")
self.con.row_factory = sqlite3.Row
self.con.executescript(
"CREATE TABLE items (key TEXT PRIMARY KEY, title TEXT, date_published TEXT);"
"CREATE TABLE fr_anchors (item_key TEXT, p_id INTEGER, page INTEGER, ordinal INTEGER, text TEXT);"
)
self.con.executemany(
"INSERT INTO items VALUES (?,?,?)",
[
("DE2VH9PD", "Medicare Program; CY 2015 PFS Final Rule", "2014-11-13"),
("6N8FPFHF", "Medicare Program; CY 2015 PFS; Correction", "2015-03-20"),
("VZW3HXDA", "CY 2016 PFS Proposed Rule", "2015-07-15"),
("JE7KYBW3", "CY 2022 PFS Final Rule", "2021-11-19"),
("YBM4IZUS", "CY 2021 PFS Final Rule", "2020-12-28"),
("MZ24MX5S", "CY 2023 PFS Final Rule", "2022-11-18"),
("PETM225D", "CY 2023 PFS Proposed Rule", "2022-07-29"),
("XFGGRBDH", "CY 2025 PFS Proposed Rule", "2024-07-31"),
("JJ6AM5HJ", "CY 2025 PFS Final Rule", "2024-12-09"),
("CORR2021", "CY 2021 PFS Final Rule; Correction", "2021-01-19"),
],
)
def _con(self):
return self.con
def get(self, key):
row = self.con.execute("SELECT * FROM items WHERE key=?", (key,)).fetchone()
if row is None:
raise KeyError(key)
class _I:
title = row["title"]
date_published = row["date_published"]
return _I()
def close(self):
self.con.close()
@pytest.fixture
def store():
s = _Store()
yield s
s.close()
def _by(rows, code, kind=None):
return [r for r in rows if r.code == code and (kind is None or r.kind == kind)]
class TestSeriesAndBands:
def test_series_ignores_modifier_rows(self, con):
s = rvu_series(con)
assert [y for y, _, _ in s["99487"]] == list(range(2015, 2027))
assert all(t != 99.0 for _, _, t in s["99487"])
def test_bands_are_deciles_among_payable_codes(self, con):
s = rvu_series(con)
b = rvu_bands(s)
# ten fillers 1..10 plus the real codes; the cheapest is band 1, dearest band 10
assert b[(2020, "F0001")] == 1
# 14 payable codes in 2020; 12345 (5.0) sorts 8th (ties break on code) → 1 + 7*10//14
assert b[(2020, "12345")] == 6
assert b[(2020, "F0010")] == 10
assert (2016, "99487") not in b # B status is not payable → no band
def test_ties_share_a_band(self):
series = {c: [(2020, "A", 1.0)] for c in ("A0001", "A0002", "A0003")}
series["B0001"] = [(2020, "A", 2.0)]
b = rvu_bands(series)
assert b[(2020, "A0001")] == b[(2020, "A0002")] == b[(2020, "A0003")] == 1
assert b[(2020, "B0001")] == 8 # index 3 of 4 → 1 + 30//4
class TestDerive:
def test_kinds_are_closed(self):
assert KINDS == (
"becomes-payable",
"revalued",
"status-change",
"ends",
"telehealth-listed",
)
def test_becomes_payable_from_status_flip_and_appearance(self, con, store):
rows = build(con, store)
r = _by(rows, "99487", "becomes-payable")
assert (
len(r) == 1
and r[0].year == 2017
and r[0].prior_status == "B"
and r[0].status == "A"
)
assert r[0].effective_date == dt.date(2017, 1, 1)
r = _by(rows, "99439", "becomes-payable")
assert len(r) == 1 and r[0].year == 2021 and r[0].prior_status == ""
# anchor: kind-matched (created/replaces) from the same year, lowest p_id
assert (r[0].item_key, r[0].p_id, r[0].anchor_kind) == (
"YBM4IZUS",
686,
"created",
)
def test_left_censored_first_year_needs_fr_evidence(self, con, store):
rows = build(con, store)
r = _by(rows, "99490", "becomes-payable")
assert len(r) == 1 and r[0].year == 2015
# the final rule's adopted_cpt anchor wins over the correction notice
assert r[0].item_key == "DE2VH9PD" and r[0].correction is False
assert r[0].effective_date == dt.date(2015, 1, 1)
# filler codes are payable in 2015 too but have no FR evidence → no row
assert not _by(rows, "F0001")
def test_ends_on_disappearance_and_on_non_payable_flip(self, con, store):
rows = build(con, store)
r = _by(rows, "G2058", "ends")
assert (
len(r) == 1
and r[0].year == 2021
and r[0].prior_status == "A"
and r[0].status == ""
)
assert (r[0].item_key, r[0].anchor_kind, r[0].p_id) == (
"YBM4IZUS",
"replaced_by",
1578,
)
r = _by(rows, "99441", "ends")
assert len(r) == 1 and r[0].year == 2025 and r[0].anchor_kind == "deleted"
r = _by(rows, "12345", "ends")
assert (
len(r) == 1
and r[0].year == 2023
and r[0].prior_status == "R"
and r[0].status == "C"
)
assert r[0].item_key == "" and r[0].p_id == 0 # no FR mention at all
def test_status_change_only_between_payable_statuses(self, con, store):
rows = build(con, store)
r = _by(rows, "12345", "status-change")
assert [(x.year, x.prior_status, x.status) for x in r] == [(2019, "A", "R")]
assert not _by(
rows, "99441", "status-change"
) # N→A is becomes-payable, not status-change
def test_revalued_carries_delta_and_nearest_anchor(self, con, store):
rows = build(con, store)
r = _by(rows, "99487", "revalued")
assert len(r) == 1 and r[0].year == 2022
assert r[0].delta_pct == pytest.approx(0.475)
assert r[0].item_key == "JE7KYBW3" and r[0].anchor_kind == "crosswalk"
def test_telehealth_listed_once_per_run_of_years(self, con, store):
rows = build(con, store)
r = _by(rows, "99441", "telehealth-listed")
assert [x.year for x in r] == [2022] # 2023 continues the 2022 run
assert r[0].item_key == "JE7KYBW3" and r[0].p_id == 555
def test_correction_only_anchor_moves_the_effective_date(self, con, store):
rows = build(con, store)
r = _by(rows, "99441", "becomes-payable")
assert len(r) == 1 and r[0].year == 2021
assert r[0].correction is True and r[0].item_key == "CORR2021"
assert r[0].effective_date == dt.date(2021, 1, 19)
def test_neighbouring_year_correction_keeps_january_first(self, con, store):
# 20982-style: a 2016 revalue whose only FR mention is the CY2015
# correction notice (±1 year) stays dated 2016-01-01.
write_events(
con,
"55555",
[
_rvu("55555", 2016, "revalued", "+20.0%"),
_fr("55555", 2015, "created", "6N8FPFHF", 3),
],
)
con.executemany(
"INSERT INTO pfs.rvu VALUES (?,?,?,?,?,?)",
[
("55555", None, "x", "A", 1.0, 2015),
("55555", None, "x", "A", 1.2, 2016),
],
)
rows = build(con, store)
r = _by(rows, "55555", "revalued")[0]
assert r.item_key == "6N8FPFHF" and r.correction is True
assert r.effective_date == dt.date(2016, 1, 1)
# and its 2015 becomes-payable IS dated by the correction (same year)
assert _by(rows, "55555", "becomes-payable")[0].effective_date == dt.date(
2015, 3, 20
)
def test_bands_and_family_carried(self, con, store):
rows = build(
con,
store,
family_of=lambda c: (
"CCM" if c in {"99490", "99487", "99439", "G2058"} else ""
),
)
r = _by(rows, "99487", "becomes-payable")[0]
assert r.family == "CCM" and r.rvu_band >= 1 and r.non_fac_total == 2.6
e = _by(rows, "G2058", "ends")[0]
assert e.non_fac_total == 1.05 and e.rvu_band >= 1 # from the last payable year
assert _by(rows, "12345", "ends")[0].family == ""
def test_rows_sorted_and_unique(self, con, store):
rows = build(con, store)
keys = [(r.code, r.year, r.kind) for r in rows]
assert len(keys) == len(set(keys))
assert keys == sorted(keys)
def test_derive_is_pure(self):
series = {"X1234": [(2015, "A", 1.0), (2016, "A", 1.0), (2017, "N", 0.0)]}
rows = derive(series, {}, (2015, 2017), family_of=lambda c: "", rule_info={})
assert [(r.kind, r.year) for r in rows] == [("ends", 2017)]
class TestPersist:
def test_write_and_read_roundtrip(self, con, store):
rows = build(con, store)
n = write_exposures(con, rows)
assert n == len(rows) > 0
back = read_exposures(con)
assert back == rows
assert read_exposures(con, code="99487") == _by(rows, "99487")
assert all(isinstance(r, ExposureRow) for r in back)
write_exposures(con, rows) # idempotent full replace
assert len(read_exposures(con)) == n
class TestControls:
def test_controls_match_status_and_band_outside_window(self, con, store):
rows = build(con, store)
write_exposures(con, rows)
# 99487 becomes payable 2017 (A, band by 2.6). Controls: A-status codes in the
# same band that year with no exposure in 2015..2019.
ctrl = control_codes(con, "99487", 2017, window=2)
codes = {c for c, *_ in ctrl}
assert "99487" not in codes
assert (
"99490" not in codes or True
) # 99490's only exposure is 2015 → outside window, allowed
assert all(status == "A" for _, _, status, _ in ctrl)
band = _by(rows, "99487", "becomes-payable")[0].rvu_band
assert all(b == band for *_, b in ctrl)
# 12345 has a 2019 status-change → inside the window → excluded even if same band
assert "12345" not in codes
def test_unknown_code_year_is_empty(self, con, store):
write_exposures(con, build(con, store))
assert control_codes(con, "ZZZZZ", 2017) == []