Files
stack/tests/pfs/test_stance.py
kert 71227e879d feat(pfs): CPT guideline facts from the licensed books (R69), stance-v5 coding event from facts, link relevance by kind, home-and-residence pair phrases (R74)
pfs guideline-facts writes pfs.code_guideline_fact: selection basis, MDM level,
time thresholds, deletions between editions, copy-guarded scope points and the
Feb 2021 Panel action, each cited by (edition, page). stance-v5 states the CPT
change in the coding event and drops keyword-only coding events; v5 ships on
the golden set (recall 14/27, direction 11/14 vs v4 13/27, 10/13).
2026-11-12 17:19:09 -05:00

1508 lines
58 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""pfs.stance — comment stance per family event (task 9)."""
from __future__ import annotations
import dataclasses
import json
import re
from pathlib import Path
import duckdb
import pytest
from bib.item import Rule
from bib.store import Store
from pfs.codetables import (
CmsSummaryRow,
CommenterRow,
EventRow,
ReactionRow,
RoundRow,
StanceRow,
ensure_tables,
read_stances,
write_cms_summaries,
write_events,
write_rounds,
write_stances,
)
from pfs.curated import load_curated
from pfs.runlog import RunLog
from pfs.stance import (
DEFAULT_METHOD,
GOLDEN_PATH,
PROMPT_VERSIONS,
STANCE_PROMPT_VERSION,
EventChoice,
Subject,
classify_comment,
clean_text,
docket_events,
docket_rule_year,
evidence_span,
position_rows,
raw_positions,
rollup,
stance_schema,
stances_for,
)
TEXT = (
"We support the proposed increase in work RVUs for CPT code 99342 because "
"home visits take far longer than office visits for frail patients. "
"However we urge CMS to reconsider the new G0318 prolonged services add-on "
"code, which will be too hard to bill in practice. Sincerely, Jane Doe"
)
EV_VAL = "we support the proposed increase in work RVUs for CPT code 99342"
EV_G = "we urge CMS to reconsider the new G0318 prolonged services add-on"
FAM = load_curated()["HOME-VISITS"]
def _round(code="99342", year=2023):
return RoundRow(
"HOME-VISITS", code, year, "PETM225D", "final", 1, 0, "fr-para", "",
2.0, "", None, None, "", "", None, 1.9, 1.8, "refine", 0, "", "", "fr",
) # fmt: skip
def _event(code, year, kind):
return EventRow(code, year, kind, "", "", "", 0, 0, "fr", True, "")
@pytest.fixture
def con():
c = duckdb.connect(":memory:")
ensure_tables(c)
yield c
c.close()
@pytest.fixture
def events():
return [
EventChoice("HOME-VISITS:2023:valuation", "Work RVUs for 99342", 2023),
EventChoice("HOME-VISITS:2023:coding", "CPT Editorial Panel changes", 2023),
EventChoice("HOME-VISITS:2023:G0318", "New add-on G0318", 2023),
EventChoice("HOME-VISITS:2023:other", "Anything else", 2023),
]
class Ask:
model = "qwen-test"
def __init__(self, positions):
self.positions = positions
self.calls = []
def __call__(self, system, user, schema):
self.calls.append((system, user, schema))
if self.positions is None:
return None
return {"positions": self.positions}
def pos(event, stance, reason="r", evidence=""):
return {"event": event, "stance": stance, "reason": reason, "evidence": evidence}
def S(item, event, stance, docket="D1", reason="", evidence=""):
return StanceRow("HOME-VISITS", item, docket, event, stance, reason, evidence, "x")
# ── docket year ──────────────────────────────────────────────────────
def test_docket_rule_year_matches_rag():
from llm.rag import _docket_rule_year
for d in ("2022-09-06", "2023-03-01", "2023-07-01", "bad", "", None, "2023"):
assert docket_rule_year(d) == _docket_rule_year(d)
assert docket_rule_year("2022-09-06") == 2023
assert docket_rule_year("2023-03-01") == 2023
assert docket_rule_year("bad") == 0
# ── events ───────────────────────────────────────────────────────────
TELE_PARA = (
"For home or residence visits CPT codes 99345 and 99350 apply. "
"These services are similar to services currently on the Medicare Telehealth "
"Services List, such as CPT codes 99356 and 99357. Similarly, we believe that "
"these proposed HCPCS G codes would qualify. Therefore, we are proposing to add "
"proposed HCPCS codes GXXX1, GXXX2, and GXXX3 to the Medicare Telehealth "
"Services List on a Category 1 basis."
)
CODING_PARA = (
"The CPT guidelines now let home or residence visits (99341, 99342) be "
"leveled by medical decision making or total time."
)
ALIAS_PARA = (
"We are proposing that prolonged home or residence services would be reportable "
"under GXXX3 ( Prolonged home or residence evaluation and management service(s) "
"beyond the total time for the primary service; each extra 15 minutes "
"(list separately in addition to CPT codes 99345, 99350 for home or residence "
"evaluation and management services). (Do not report GXXX3 on the same date "
"as 99358))."
)
def _store(title, paras):
s = Store(":memory:")
key = s.create(Rule(title=title))
c = s._con() # noqa: SLF001
c.executemany(
"INSERT INTO fr_anchors (item_key, p_id, page, ordinal, text) VALUES (?,?,?,?,?)",
[(key, p, 1, p, t) for p, t in paras.items()],
)
c.commit()
return s, key
@pytest.fixture
def home_store():
s, key = _store(
"CY 2023 Payment Policies Under the Physician Fee Schedule; Proposed Rule",
{
5: TELE_PARA,
6: "An unrelated paragraph about 99342.",
7: CODING_PARA,
880: ALIAS_PARA,
},
)
yield s, key
s.close()
def _fam(key):
links = tuple(
dataclasses.replace(ln, alias_item_key=key) if ln.aliases else ln
for ln in FAM.links
)
return dataclasses.replace(FAM, links=links)
def test_docket_events_cy2023_home_visits(con, home_store):
store, key = home_store
write_rounds(con, "HOME-VISITS", [_round(), _round(year=2008)])
write_events(con, "99342", [_event("99342", 2023, "cpt_panel")])
fam = _fam(key)
got = docket_events(con, fam, 2023, store, guidelines=False)
assert [e.event_key for e in got] == [
"HOME-VISITS:2023:valuation",
"HOME-VISITS:2023:coding",
"HOME-VISITS:2023:telehealth",
"HOME-VISITS:2023:G0318",
"HOME-VISITS:2023:other",
]
assert all(e.rule_year == 2023 for e in got)
val, coding, tele, g, other = (e.text for e in got)
assert (
"99342 (RUC recommended 2.00; CMS proposed 1.90)" in val and "2008" not in val
)
assert (
"CPT deleted 99324–99328, 99334–99337, 99339, 99340 and 99343; those "
"services are reported with 99341, 99342, 99344, 99345 and 99347–99350 "
"from CY2023" in coding
)
assert "1 cpt panel" in coding
assert "Rule text: [\u00b67] " + CODING_PARA in coding # densest first
assert coding.index("[\u00b67]") < coding.index("[\u00b6880]")
assert tele.endswith(
"GXXX3 to the Medicare Telehealth Services List on a Category 1 basis."
)
assert "unrelated" not in tele
assert g.startswith("Linked code G0318 (prolonged-add-on), new in the CY2023 rule")
assert "called it GXXX3: Prolonged home or residence evaluation" in g
assert (
"management services). Rule text: [\u00b65]" in g or "Rule text: [\u00b6" in g
)
assert "[\u00b6880]" in g
assert "99341" in other and "other codes or services are not given" in other
assert (
docket_events(con, fam, 2023, store, guidelines=False) == got
) # deterministic
def test_docket_events_without_store_uses_lineage_only(con):
write_events(
con,
"99341",
[_event("99341", 2024, "telehealth_list"), _event("99341", 2024, "created")],
)
got = docket_events(con, FAM, 2024)
assert [e.event_key.split(":")[-1] for e in got] == [
"coding",
"telehealth",
"other",
]
assert "1 created" in got[0].text and "deleted" not in got[0].text
assert got[1].text.endswith("(lineage: telehealth list change for 99341)")
assert [e.event_key for e in docket_events(con, FAM, 1999)] == [
"HOME-VISITS:1999:other"
]
def test_docket_events_link_without_alias_or_paragraph(con):
got = docket_events(con, FAM, 2026)
assert got[0].event_key == "HOME-VISITS:2026:G2211"
assert got[0].text == "Linked code G2211 (add-on), new in the CY2026 rule"
store = Store(":memory:") # alias paragraph absent
g = docket_events(con, FAM, 2023, store)[-2]
assert g.text.endswith("the proposed rule called it GXXX3")
def test_telehealth_paragraph_with_a_range_and_item_fallback(con):
s, _ = _store(
"CY 2024 Payment Policies Under the Physician Fee Schedule; Final Rule",
{
1: "Telehealth is covered for CPT codes 99324-99328 as proposed.",
2: "Telehealth for something else, 12345.",
},
)
got = docket_events(con, FAM, 2024, s, guidelines=False)
assert [e.event_key.split(":")[-1] for e in got] == ["telehealth", "other"]
assert "99324-99328" in got[0].text
s.close()
s2, _ = _store("CY 2024 Payment Policies; PFS", {1: "Telehealth for 12345."})
assert len(docket_events(con, FAM, 2024, s2, guidelines=False)) == 1
assert len(docket_events(con, FAM, 2030, s2, guidelines=False)) == 1
s2.close()
def test_descriptor_without_do_not_clause(con):
s, key = _store(
"CY 2023 Payment Policies Under the Physician Fee Schedule; Proposed Rule",
{880: "We propose GXXX3 ( Prolonged home visit (each 15 minutes) of services."},
)
g = docket_events(con, _fam(key), 2023, s)[-2]
assert (
"called it GXXX3: Prolonged home visit (each 15 minutes) of services." in g.text
)
s.close()
s3, key3 = _store(
"CY 2023 Payment Policies Under the Physician Fee Schedule; Proposed Rule",
{880: "no placeholder here"},
)
assert docket_events(con, _fam(key3), 2023, s3)[-2].text.endswith("called it GXXX3")
s3.close()
def test_docket_events_round_without_values(con):
r = dataclasses.replace(
_round(), ruc_work_rvu=None, proposed_work_rvu=None, final_work_rvu=None
)
write_rounds(con, "HOME-VISITS", [r])
val = docket_events(con, FAM, 2023)[0]
assert val.text.endswith("99342") and "RUC" not in val.text
r3 = dataclasses.replace(_round(), ruc_work_rvu=1.9)
write_rounds(con, "HOME-VISITS", [r3])
assert docket_events(con, FAM, 2023)[0].text.endswith(
"99342 (RUC recommended and CMS proposed 1.90)"
)
r2 = dataclasses.replace(_round(), proposed_work_rvu=None)
write_rounds(con, "HOME-VISITS", [r2])
assert docket_events(con, FAM, 2023)[0].text.endswith(
"99342 (RUC recommended 2.00)"
)
def test_stance_schema(events):
sc = stance_schema(events)
item = sc["properties"]["positions"]["items"]
assert item["properties"]["event"]["enum"] == [e.event_key for e in events]
assert item["properties"]["stance"]["enum"] == [
"support",
"modify",
"oppose",
"unclear",
]
assert item["properties"]["reason"]["maxLength"] == 200
assert item["properties"]["evidence"]["maxLength"] == 200
assert PROMPT_VERSIONS == {
"multi": "stance-v5",
"per-event": "stance-v5-per-event",
}
assert STANCE_PROMPT_VERSION == PROMPT_VERSIONS[DEFAULT_METHOD]
# ── classify_comment ─────────────────────────────────────────────────
def _classify(positions, text=TEXT, events=None, ask=None):
ask = ask or Ask(positions)
return classify_comment(
text, events, ask, family="HOME-VISITS", item_key="K1", docket="D1"
)
def test_prompt_lists_events_and_rules(events):
ask = Ask([])
_classify([], events=events, ask=ask)
system, user, schema = ask.calls[0]
assert "HOME-VISITS:2023:G0318: New add-on G0318" in system
for rule in (
"support = the commenter endorses what CMS proposed",
"oppose = the commenter rejects the proposal: asks CMS not to adopt or finalize it, to withdraw or delay it",
"modify = the commenter accepts the proposal but asks for changes or additions",
"give no position at all",
"at most one position per event",
"other codes or other specialties",
):
assert rule in system
assert user == TEXT
assert schema == stance_schema(events)
def test_none_reply_is_none(events):
assert _classify(None, events=events) is None
def test_rows_carry_fields(events):
rows = _classify(
[pos("HOME-VISITS:2023:valuation", "support", "likes increase", EV_VAL)],
events=events,
)
assert rows == [
StanceRow(
"HOME-VISITS",
"K1",
"D1",
"HOME-VISITS:2023:valuation",
"support",
"likes increase",
"We support the proposed increase in work RVUs for CPT code 99342",
"model:qwen-test@stance-v5",
)
]
def test_unknown_event_in_reply_dropped(events):
rows = _classify(
[
pos("HOME-VISITS:2023:nope", "support", "r", EV_VAL),
pos(events[3].event_key, "oppose", "r", EV_VAL),
],
events=events,
)
assert [r.event_key for r in rows] == [events[3].event_key]
def test_bad_stance_and_non_dict_dropped(events):
ask = Ask([pos(events[0].event_key, "maybe", "r", EV_VAL), "junk"])
assert _classify(None, events=events, ask=ask) == []
def test_evidence_must_occur_in_text_else_position_dropped(events):
rows = _classify(
[
pos(events[0].event_key, "support", "r", EV_VAL),
pos(events[1].event_key, "oppose", "r", "this sentence was never written anywhere"),
pos(events[2].event_key, "modify", "r", "too short quote"),
pos(events[3].event_key, "unclear", "r", ""),
],
events=events,
) # fmt: skip
assert [(r.event_key, r.stance) for r in rows] == [(events[0].event_key, "support")]
def test_evidence_is_the_longest_verbatim_span():
text = "Intro words here. We urge CMS to reconsider the new G0318 prolonged services add-on code, which will be hard."
q = "CMS to reconsider the new G0318 prolonged services add-on code, and more"
# the matched run is cut at the quote's own words, in the comment's own spelling
assert (
evidence_span(q, text)
== "CMS to reconsider the new G0318 prolonged services add-on code,"
)
assert (
evidence_span("nothing in common whatsoever with the text at all", text) == ""
)
assert evidence_span("", text) == ""
def test_evidence_span_capped_at_200_chars():
text = " ".join(f"word{i}" for i in range(80))
span = evidence_span(text, text)
assert 0 < len(span) <= 200 and text.startswith(span) and not span.endswith(" ")
def test_evidence_window_match_is_case_and_punctuation_blind(events):
ev = "WE SUPPORT, the proposed increase: in work RVUs for CPT code 99342 (and more)"
rows = _classify([pos(events[0].event_key, "support", "r", ev)], events=events)
assert rows[0].evidence.startswith("We support the proposed increase in work RVUs")
def test_clean_text_decodes_html():
assert (
clean_text("people&#39;s &quot;x&quot;<br/>next<br />end")
== 'people\'s "x"\nnext\nend'
)
assert clean_text(None) == ""
rows = _classify(
[
pos(
"HOME-VISITS:2023:other",
"support",
"r",
"this is what the people's own words say here",
)
],
text="this is what the people&#39;s own words say here",
events=[EventChoice("HOME-VISITS:2023:other", "o", 2023)],
)
assert rows[0].evidence == "this is what the people's own words say here"
def test_one_position_per_event_first_with_evidence_wins(events):
k = events[0].event_key
rows = _classify(
[
pos(k, "oppose", "first, no evidence", "nothing like the text of this comment here"),
pos(k, "support", "second", EV_VAL),
pos(k, "modify", "third", EV_VAL),
],
events=events,
) # fmt: skip
assert [(r.stance, r.reason) for r in rows] == [("support", "second")]
def test_one_quote_backs_one_position_only(events):
rows = _classify(
[
pos(events[0].event_key, "oppose", "a", EV_VAL),
pos(events[1].event_key, "oppose", "b", EV_VAL),
pos(events[2].event_key, "modify", "c", EV_G),
],
events=events,
)
assert [(r.event_key, r.reason) for r in rows] == [
(events[0].event_key, "a"),
(events[2].event_key, "c"),
]
def test_reason_truncated(events):
rows = _classify(
[pos(events[0].event_key, "support", "x" * 500, EV_VAL)], events=events
)
assert len(rows[0].reason) == 200
assert raw_positions(None, events) is None
assert raw_positions({}, events) == []
assert (
raw_positions(
{"positions": [pos(events[0].event_key, "oppose", None, None)]}, events
)[0]["reason"]
== ""
)
def test_position_rows_uses_given_extractor(events):
raw = [pos(events[0].event_key, "modify", "r", EV_VAL)]
(r,) = position_rows(raw, TEXT, family="F", item_key="K", docket="D", extractor="x")
assert (r.family, r.item_key, r.docket, r.extractor, r.stance) == (
"F",
"K",
"D",
"x",
"modify",
)
# ── stances_for (ledger) ─────────────────────────────────────────────
def _commenter(key, cid=None, org="individual"):
return CommenterRow(
key, "D1", cid or "C-" + key, "", org, "", "", 0, "regs_gov_fields", "", "regs", "HOME-VISITS"
) # fmt: skip
def _run(tmp_path, ask, commenters, texts, events, **kw):
log = RunLog(tmp_path / "stance.jsonl")
out = stances_for(
commenters, texts, events, ask, log, family="HOME-VISITS", docket="D1", **kw
)
return out, log
def test_ledger_keeps_the_raw_reply_and_filters_run_on_replay(tmp_path, events):
ask = Ask(
[
pos(events[0].event_key, "support", "likes it", EV_VAL),
pos(events[0].event_key, "oppose", "dup", EV_VAL),
pos(events[1].event_key, "oppose", "bad", "not a quote from the text at all"),
pos("HOME-VISITS:2023:zzz", "support"),
]
) # fmt: skip
(rows, counts), _ = _run(tmp_path, ask, [_commenter("K1")], {"K1": TEXT}, events)
assert counts == {
"done": 1, "skipped": 0, "failed": 0, "failed_pending": 0,
"pending": 0, "no_text": 0,
} # fmt: skip
assert [(r.event_key, r.stance, r.reason) for r in rows] == [
(events[0].event_key, "support", "likes it")
]
assert rows[0].extractor == f"model:qwen-test@{STANCE_PROMPT_VERSION}"
ledger = [
json.loads(line)
for line in (tmp_path / "stance.jsonl").read_text().splitlines()
]
raw = ledger[0]["output"]["positions"]
assert (
len(raw) == 3 and raw[1]["stance"] == "oppose"
) # everything valid, unfiltered
# replay with no model: same rows, the model is never asked
(again, c2), _ = _run(
tmp_path, None, [_commenter("K1")], {"K1": TEXT}, events, model="qwen-test"
)
assert again == rows and c2["done"] == 0 and c2["pending"] == 0
assert len(ask.calls) == 1
def test_no_llm_without_a_ledger_is_pending(tmp_path, events):
(rows, counts), _ = _run(
tmp_path, None, [_commenter("K1")], {"K1": TEXT}, events, model="qwen-test"
)
assert rows == [] and counts["pending"] == 1
def test_stances_for_resume_limit_failed(tmp_path, events):
cs = [_commenter(k) for k in ("K1", "K2", "K3", "K4", "K5")]
texts = {k: TEXT for k in ("K1", "K2", "K3", "K4")} # K5 has no text
ask = Ask([pos(events[3].event_key, "oppose", "r", EV_VAL)])
(rows, counts), _ = _run(tmp_path, ask, cs, texts, events, limit=2)
assert counts["done"] == 2 and counts["pending"] == 2 and counts["no_text"] == 1
assert len(ask.calls) == 2 and len(rows) == 2
(rows, counts), _ = _run(tmp_path, ask, cs, texts, events)
assert counts["skipped"] == 2 and counts["done"] == 2 and counts["pending"] == 0
assert len(rows) == 4 and len(ask.calls) == 4
bad = Ask(None)
(rows, counts), _ = _run(tmp_path, bad, [_commenter("K9")], {"K9": TEXT}, events)
assert counts["failed"] == 1 and counts["pending"] == 1 and rows == []
(rows, counts), _ = _run(tmp_path, bad, [_commenter("K9")], {"K9": TEXT}, events)
assert counts["failed_pending"] == 1 and len(bad.calls) == 1
good = Ask([pos(events[3].event_key, "support", "r", EV_VAL)])
(rows, counts), _ = _run(
tmp_path, good, [_commenter("K9")], {"K9": TEXT}, events, retry_failed=True
)
assert counts["done"] == 1 and len(rows) == 1
def test_stances_for_event_change_reruns(tmp_path, events):
ask = Ask([pos(events[3].event_key, "support", "r", EV_VAL)])
c, t = [_commenter("K1")], {"K1": TEXT}
_run(tmp_path, ask, c, t, events)
other = [*events[:3], EventChoice(events[3].event_key, "different text", 2023)]
(_rows, counts), _ = _run(tmp_path, ask, c, t, other)
assert counts["done"] == 1
def test_stances_for_progress(tmp_path, events):
seen = []
_run(
tmp_path, Ask([]), [_commenter("K1")], {"K1": TEXT}, events,
progress=lambda u, s: seen.append((u, s)),
) # fmt: skip
assert seen == [("K1", "done")]
# ── table ────────────────────────────────────────────────────────────
def test_stance_table_roundtrip(con):
a = S("K1", "HOME-VISITS:2023:other", "support", reason="r", evidence="e")
b = S("K2", "HOME-VISITS:2023:other", "oppose", docket="D2")
c = StanceRow("OTHER", "K3", "D1", "OTHER:2023:other", "modify", "", "", "x")
write_stances(con, "HOME-VISITS", "D1", [a])
write_stances(con, "HOME-VISITS", "D2", [b])
write_stances(con, "OTHER", "D1", [c])
assert read_stances(con, "HOME-VISITS") == [a, b]
assert read_stances(con, "HOME-VISITS", docket="D2") == [b]
write_stances(con, "HOME-VISITS", "D1", []) # replaces just that docket
assert read_stances(con, "HOME-VISITS") == [b]
assert read_stances(con, "OTHER") == [c]
def test_reviewed_flag_defaults_false_and_roundtrips(con):
a = S("K1", "E", "support")
assert a.reviewed is False
b = dataclasses.replace(S("K2", "E", "oppose"), reviewed=True)
write_stances(con, "HOME-VISITS", "D1", [a, b])
got = read_stances(con, "HOME-VISITS")
assert [(r.item_key, r.reviewed) for r in got] == [("K1", False), ("K2", True)]
def test_old_stance_table_gains_reviewed_false():
c = duckdb.connect(":memory:")
c.execute("CREATE SCHEMA pfs")
c.execute(
"CREATE TABLE pfs.comment_stance (family VARCHAR, item_key VARCHAR, docket VARCHAR, "
"event_key VARCHAR, stance VARCHAR, reason VARCHAR, evidence VARCHAR, extractor VARCHAR)"
)
c.execute(
"INSERT INTO pfs.comment_stance VALUES ('F','K','D','E','support','','','x')"
)
assert read_stances(c, "F")[0].reviewed is False # an old replica: short rows
ensure_tables(c)
assert read_stances(c, "F")[0].reviewed is False
c.execute("INSERT INTO pfs.comment_stance (family, item_key) VALUES ('G','K2')")
assert read_stances(c, "G")[0].reviewed is False # the column default
# ── rollup ───────────────────────────────────────────────────────────
def _base(**kw):
d = dict(
family="HOME-VISITS", period="D1", period_kind="docket", year=2023,
n_items=9, n_total=40, stance_support=0, stance_oppose=0,
stance_modify=0, stance_unclear=0, sample_json='[{"k": 1}]',
) # fmt: skip
return ReactionRow(**{**d, **kw})
V, C, T, G, O = (
f"HOME-VISITS:2023:{s}"
for s in ("valuation", "coding", "telehealth", "G0318", "other")
)
ORDER = (V, C, T, G, O)
def test_rollup_counts_distinct_commenters():
rows = [
# K1: valuation unclear, coding support -> first non-unclear = support
S("K1", V, "unclear"), S("K1", C, "support"), S("K1", G, "oppose"),
# K2: telehealth comes before G0318 and other in docket event order
S("K2", O, "oppose"), S("K2", O, "support"), S("K2", T, "modify"), S("K2", G, "oppose"),
# K3: all unclear
S("K3", V, "unclear"), S("K3", O, "unclear"),
# K4: same stance on several events counts once
S("K4", V, "oppose"), S("K4", G, "oppose"),
] # fmt: skip
got = rollup(rows, base=_base(), order=ORDER, n_commenters=52)
assert (
got.stance_support,
got.stance_modify,
got.stance_oppose,
got.stance_unclear,
) == (1, 1, 1, 1)
assert (got.n_classified, got.n_commenters) == (3, 52)
assert got.sample_json == '[{"k": 1}]' and got.n_items == 9 and got.period == "D1"
ev = json.loads(got.events_json)
assert list(ev) == [V, C, T, G, O] # docket_events order, not key-string order
assert ev[V] == {"support": 0, "modify": 0, "oppose": 1, "unclear": 2}
assert ev[C] == {"support": 1, "modify": 0, "oppose": 0, "unclear": 0}
assert ev[T] == {"support": 0, "modify": 1, "oppose": 0, "unclear": 0}
assert ev[G] == {"support": 0, "modify": 0, "oppose": 3, "unclear": 0}
assert ev[O] == {"support": 0, "modify": 0, "oppose": 1, "unclear": 1}
def test_rollup_order_beats_key_order_and_unknown_events_sort_last():
# key-string order would put "G0318" before "telehealth"; docket order does not
rows = [
S("K1", G, "oppose"),
S("K1", T, "support"),
S("K1", "X:2023:zzz", "modify"),
]
got = rollup(rows, base=_base(), order=ORDER)
assert got.stance_support == 1 and got.n_commenters is None
assert list(json.loads(got.events_json)) == [T, G, "X:2023:zzz"]
# with no order given, the event key decides
assert rollup(rows, base=_base()).stance_oppose == 1
def test_rollup_empty_is_zero():
got = rollup([], base=_base(stance_support=3), n_commenters=4)
assert got.stance_support == 0 and got.events_json == "{}"
assert (got.n_classified, got.n_commenters) == (0, 4)
def test_rollup_ignores_cms_summaries(con):
write_cms_summaries(
con,
"HOME-VISITS",
[CmsSummaryRow("HOME-VISITS", "R1", 2023, "final", 1, 2, "99342", "a majority of commenters", "support", "finalize_as_proposed", "regex")],
) # fmt: skip
rows = [S("K1", "HOME-VISITS:2023:other", "oppose")]
got = rollup(rows, base=_base())
assert (got.stance_support, got.stance_oppose) == (0, 1)
# ── paragraphs in event texts (R64) ──────────────────────────────────
def _by_slug(evs, slug):
return next(e.text for e in evs if e.event_key.endswith(":" + slug))
def test_excerpt_window_and_short_paragraphs():
from pfs.stance import _TELEHEALTH_WORDS, _excerpt
assert (
_excerpt("short telehealth\ntext", _TELEHEALTH_WORDS)
== "short telehealth text"
)
long = (
"filler " * 200
+ "telehealth one. telehealth two. telehealth three. "
+ "tail " * 200
)
out = _excerpt(long, _TELEHEALTH_WORDS)
assert len(out) <= 600 and out.count("telehealth") == 3 and long.count(out) == 1
assert not out.startswith(" ") and not out.endswith(" ")
no_hit = "w " * 500
assert _excerpt(no_hit, _TELEHEALTH_WORDS) in no_hit
assert len(_excerpt(no_hit, _TELEHEALTH_WORDS)) <= 600
tail_hit = "x " * 400 + "telehealth end"
assert _excerpt(tail_hit, _TELEHEALTH_WORDS).endswith("telehealth end")
def test_paragraph_choice_is_by_density_and_names_the_family(con):
s, key = _store(
"CY 2023 Payment Policies Under the Physician Fee Schedule; Proposed Rule",
{
1: "Telehealth " + "filler " * 80 + "for 99341.", # one hit, long
2: "Telehealth and telehealth for 99342.", # densest
3: "Telehealth for home or residence visits generally.", # phrase, no code
4: "Telehealth for 99999 only.", # names no family code
5: "99341 with no keyword at all.",
6: "Telehealth for 99345 too " + "w " * 40,
},
)
tele = _by_slug(docket_events(con, FAM, 2023, s, guidelines=False), "telehealth")
assert tele.index("[¶2]") < tele.index("[¶3]")
assert tele.count("[¶") == 2 # at most two paragraphs
assert "[¶4]" not in tele and "[¶5]" not in tele
s.close()
def test_time_counts_a_quarter_of_the_guideline_words(con):
s, key = _store(
"CY 2023 Payment Policies Under the Physician Fee Schedule; Proposed Rule",
{
1: "time time time time time time for 99341 " + "filler " * 60, # weak only
2: "The guideline for 99341 is new and long text follows here for density",
3: "Deleted code 99343 is gone.",
},
)
coding = _by_slug(docket_events(con, FAM, 2023, s, guidelines=False), "coding")
assert coding.index("[¶3]") < coding.index("[¶2]")
assert coding.count("[¶") == 2 and "[¶1]" not in coding
s.close()
def test_coding_event_from_paragraphs_alone_is_v4_only(con):
s, _ = _store(
"CY 2030 Payment Policies Under the Physician Fee Schedule; Proposed Rule",
{1: "New guideline for 99341 visits."},
)
ev = docket_events(con, FAM, 2030, s, guidelines=False)
assert ev[0].event_key.endswith(":coding")
assert ev[0].text.startswith("Coding changes. Rule text: [¶1]")
s.close()
# ── per-event method ─────────────────────────────────────────────────
class EventAsk:
"""Answers per call; remembers the system prompt of each."""
model = "qwen-test"
def __init__(self, by_event):
self.by_event = by_event
self.calls = []
def __call__(self, system, user, schema):
self.calls.append((system, user, schema))
enum = schema["properties"]["positions"]["items"]["properties"]["event"]["enum"]
if len(enum) != 1:
return {"positions": []}
ans = self.by_event.get(enum[0])
return None if ans == "FAIL" else {"positions": [ans] if ans else []}
def test_per_event_asks_once_per_item_and_event(tmp_path, events):
ask = EventAsk(
{
events[0].event_key: pos(events[0].event_key, "support", "r", EV_VAL),
events[2].event_key: pos(events[2].event_key, "modify", "r", EV_G),
}
)
(rows, counts), log = _run(
tmp_path, ask, [_commenter("K1")], {"K1": TEXT}, events, method="per-event"
)
assert len(ask.calls) == 4 and counts["done"] == 4 and counts["pending"] == 0
assert [(r.event_key, r.stance) for r in rows] == [
(events[0].event_key, "support"),
(events[2].event_key, "modify"),
]
assert rows[0].extractor == "model:qwen-test@stance-v5-per-event"
# the focused prompt names only its own event
system = ask.calls[1][0]
assert events[1].event_key in system and events[0].event_key not in system
assert "exactly this event" in system
ledger = [
json.loads(x) for x in (tmp_path / "stance.jsonl").read_text().splitlines()
]
assert {e["unit"] for e in ledger} == {f"K1::{e.event_key}" for e in events}
assert {e["version"] for e in ledger} == {"stance-v5-per-event"}
# replay without a model reads the same rows
(again, c2), _ = _run(
tmp_path, None, [_commenter("K1")], {"K1": TEXT}, events,
method="per-event", model="qwen-test",
) # fmt: skip
assert again == rows and c2["pending"] == 0
# the multi-event ledger of the same model is a different version
(rows_m, c3), _ = _run(
tmp_path, None, [_commenter("K1")], {"K1": TEXT}, events, model="qwen-test"
)
assert rows_m == [] and c3["pending"] == 1
def test_per_event_limit_and_partial_item_is_pending(tmp_path, events):
ask = EventAsk(
{events[0].event_key: pos(events[0].event_key, "support", "r", EV_VAL)}
)
(rows, counts), _ = _run(
tmp_path, ask, [_commenter("K1")], {"K1": TEXT}, events, method="per-event", limit=2
) # fmt: skip
assert counts["done"] == 2 and counts["pending"] == 1 and len(ask.calls) == 2
ask.by_event[events[3].event_key] = "FAIL"
(rows, counts), _ = _run(
tmp_path, ask, [_commenter("K1")], {"K1": TEXT}, events, method="per-event"
) # fmt: skip
assert counts["failed"] == 1 and counts["skipped"] == 2 and counts["pending"] == 1
# ── scoring against a golden set ─────────────────────────────────────
def test_golden_set_is_packaged_with_pfs():
"""R67: production code reads the golden set from the pfs package, never
from tests/."""
import pfs
assert GOLDEN_PATH.is_file()
assert GOLDEN_PATH.parent == Path(pfs.__file__).resolve().parent / "golden"
assert GOLDEN_PATH.name == "stance_home_visits_cy2023.json"
assert "tests" not in GOLDEN_PATH.parts
def test_golden_fixture_shape():
from pfs.stance import golden_labels
golden = json.loads(GOLDEN_PATH.read_text())
labels = golden_labels(golden)
assert (golden["family"], golden["docket"]) == ("HOME-VISITS", "CMS-2022-0113")
assert len(golden["items"]) == 20 and len(labels) == 100
assert {e for _, e in labels} == set(golden["events"])
assert set(labels.values()) <= {"none", "support", "modify", "oppose", "unclear"}
assert sum(v != "none" for v in labels.values()) > 24
def test_score_perfect_and_imperfect():
from pfs.stance import LABELS, score
golden = json.loads(GOLDEN_PATH.read_text())
from pfs.stance import golden_labels
perfect = [
S(i, e, lab) for (i, e), lab in golden_labels(golden).items() if lab != "none"
]
sc = score(golden, perfect)
assert (sc.cell_agreement, sc.presence_agreement, sc.firm_accuracy) == (
1.0,
1.0,
1.0,
)
assert sc.passes() and sc.disagreements == ()
assert sc.confusion["support"]["support"] == sum(
v == "support" for v in golden_labels(golden).values()
)
assert list(sc.confusion) == list(LABELS)
assert score(golden, []).cell_agreement < 1 and not score(golden, []).passes()
def test_score_counts_and_severity():
from pfs.stance import score
g = {
"items": [
{
"item_key": "A",
"cells": {
"e1": {"label": "support"},
"e2": {"label": "support"},
"e3": {"label": "none"},
"e4": {"label": "unclear"},
"e5": {"label": "modify"},
"e6": {"label": "oppose"},
},
}
]
}
rows = [
S("A", "e1", "oppose"), # opposite direction: severity 3
S("A", "e2", "support"), # right
S("A", "e3", "modify"), # presence
S("A", "e4", "support"), # unclear golden
S("A", "e5", "oppose"), # firm vs other firm
] # e6 missed entirely
sc = score(g, rows)
assert (sc.cells, sc.agree, sc.presence_agree) == (6, 1, 4)
assert (sc.firm_cells, sc.firm_correct) == (4, 1)
assert [(d.event_key, d.severity) for d in sc.disagreements] == [
("e1", 3),
("e3", 2),
("e5", 2),
("e6", 2),
("e4", 1),
]
assert sc.confusion["oppose"]["none"] == 1
# R66a: golden positions e1 e2 e4 e5 e6; found e1 e2 e4 e5; right e2
assert (sc.positions, sc.found, sc.right) == (5, 4, 1)
assert (sc.recall, sc.direction) == (0.8, 0.25)
from pfs.stance import Scores
empty = Scores(0, 0, 0, 0, 0, {}, ())
assert (empty.cell_agreement, empty.presence_agreement, empty.firm_accuracy) == (
0,
0,
0,
)
assert (empty.recall, empty.direction) == (0, 0)
def test_eval_record_is_the_r66a_json():
from pfs.stance import eval_record, golden_labels, score
golden = json.loads(GOLDEN_PATH.read_text())
labels = golden_labels(golden)
firm = sorted(k for k, v in labels.items() if v != "none")
# find 3 of the hand-coded positions, 2 in the right direction, and add one
# position on a golden `none` cell
wrong = "oppose" if labels[firm[2]] != "oppose" else "support"
none_cell = next(k for k, v in sorted(labels.items()) if v == "none")
rows = [
S(*firm[0], labels[firm[0]]),
S(*firm[1], labels[firm[1]]),
S(*firm[2], wrong),
S(*none_cell, "support"),
]
sc = score(golden, rows)
rec = eval_record(golden, sc)
assert list(rec) == [
"cell",
"recall",
"direction",
"n_items",
"n_positions",
"family",
"docket",
"in_sample",
]
n = len(firm)
assert rec["recall"] == round(3 / n, 3) and rec["direction"] == round(2 / 3, 3)
assert rec["cell"] == round(sc.cell_agreement, 3)
assert (rec["n_items"], rec["n_positions"]) == (len(golden["items"]), n)
assert (rec["family"], rec["docket"]) == ("HOME-VISITS", "CMS-2022-0113")
assert rec["in_sample"] is True
# a held-out set says so in its own file
held = eval_record({**golden, "in_sample": False}, sc)
assert held["in_sample"] is False
# ── relevance filter (R65) ───────────────────────────────────────────
SUBJ_EVENTS = [
EventChoice(f"HOME-VISITS:2023:{s}", s, 2023)
for s in ("valuation", "coding", "telehealth", "G0318", "other")
]
def test_event_subjects_by_slug():
from pfs.stance import event_subjects
sub = event_subjects(FAM, SUBJ_EVENTS)
v, c, t, g, o = (sub[e.event_key] for e in SUBJ_EVENTS)
# R67: the event's subject keyword AND this family
assert v.named_in("the work RVU of 99342 is low")
assert v.named_in("payment for 99341-99343 falls") # a range names 99342
assert not v.named_in("the work RVU is low") # no family mention
assert not v.named_in("see 99341-99343") # no subject keyword
# R67a: valuation's family mention may be a pair phrase
assert v.named_in("home visit codes are undervalued")
assert v.named_in("the RVU of GXXX3") # or an offered link's alias
assert not v.named_in("nothing relevant at all")
assert c.named_in("codes 99339-99341 were deleted")
assert c.named_in("domiciliary care visit codes merge") # keyword and phrase
assert c.named_in("MDM for home or residence visits") and not c.named_in("MDM")
assert not c.named_in("codes were deleted") # no family mention
assert c.named_in("GXXX3 time counts") # the link alias names the family
# R67a: a family code is a coding subject too; coding|revised|descriptor are
# coding keywords
assert c.named_in("see 99342") and c.named_in("see 99341-99343")
assert not c.named_in("see GXXX3") # a link code is not a coding subject
assert c.named_in("these coding changes for home visit codes")
assert c.named_in("the revised home or residence visit descriptors")
assert c.named_in("descriptor of the home visit codes")
assert not c.named_in("these coding changes help")
assert t.named_in("audio-only home visit codes") and t.named_in("telehealth 99341")
assert t.named_in("telehealth for GXXX3")
assert not t.named_in("we support telehealth after the PHE")
assert not t.named_in("99341 and RVUs")
assert g.named_in("the new GXXX3 code") and g.named_in("G0318 is new")
assert g.named_in("prolonged home or residence visit time")
assert not g.named_in("prolonged visits") and not g.named_in("telehealth only")
assert not g.named_in("prolonged 99417 for office visits")
assert o.named_in("code 99342 is fine") and o.named_in("a home visit e/m change")
assert not o.named_in("telehealth and RVUs") and not o.named_in("G0318 only")
# no pair phrases and an unknown link: still well-formed; the link event's
# own code names the family
bare = dataclasses.replace(FAM, pair_phrases=())
other = event_subjects(
bare, [SUBJ_EVENTS[4], EventChoice("F:2023:G9999", "x", 2023)]
)
assert not other["HOME-VISITS:2023:other"].named_in("home visit")
assert other["F:2023:G9999"].named_in("prolonged G9999")
assert not other["F:2023:G9999"].named_in("prolonged")
# a link not offered that year does not name the family
no_link = event_subjects(FAM, SUBJ_EVENTS[2:3])
assert not no_link["HOME-VISITS:2023:telehealth"].named_in("telehealth G0318")
def test_window_must_name_this_family_and_the_subject():
"""R67, from the CY2023 live rows: a general telehealth endorsement is
dropped; a valuation complaint that names only "home visit codes" is kept
(R67a); a telehealth ask about the family's codes one sentence away is
kept."""
from pfs.stance import event_subjects
sub = event_subjects(FAM, SUBJ_EVENTS)
val, tele = SUBJ_EVENTS[0].event_key, SUBJ_EVENTS[2].event_key
general = (
"This is a significant hit to our ability to provide primary care. "
"We support CMS efforts to expand telehealth services and encourage these "
"efforts continue after the end of the PHE. Many patients can now get care."
)
q = "We support CMS efforts to expand telehealth services and encourage these"
assert _rows([pos(tele, "support", "r", q)], general, sub) == []
phrase_only = (
"We support the nomination of misvalued home visit codes. By combining these "
"two code sets and not increasing the value of the home visit codes our "
"practices would incur a seven percent decrease in revenue next year."
)
q2 = "By combining these two code sets and not increasing the value"
kept2 = _rows([pos(val, "oppose", "r", q2)], phrase_only, sub)
assert [(r.event_key, r.stance) for r in kept2] == [(val, "oppose")]
ask = (
"Telehealth flexibilities matter to our patients. We recommend that all "
"codes listed above be retained through the end of the year. This includes "
"the home visit codes 99347-99350 for established patients."
)
q3 = "We recommend that all codes listed above be retained through the end"
kept = _rows([pos(tele, "modify", "r", q3)], ask, sub)
assert [(r.event_key, r.stance) for r in kept] == [(tele, "modify")]
#: CY2023 comment KUAPZ8ER (BJC HealthCare), as posted on CMS-2022-0113
KUAPZ8ER_CODING = (
"Changes to beneficiary attribution protocols due to revised coding policies "
"CMS proposes updates to beneficiary attribution criteria, including updating "
"the roster of primary care service CPTs to include new prolonged services codes "
"for SNF and \u201chome\u201d settings (Gxxx2 and Gxxx3), chronic pain management "
"(Gyyy1 and Gyyy2) and revising billing protocols for those \u201chome\u201d services "
"(99341\u201499350) to identify and include primary care services provided in "
"assisted living facilities, group homes, custodial care facilities, and "
"residential substance use treatment facilities. BJC strongly supports this "
"proposal both because it will likely increase our assigned beneficiary "
"population, but also because these coding changes will help bring transparency "
"to the services provided in those settings. Under current coding and billing "
"conventions, \u201chome\u201d services are all lumped together and thus difficult to"
)
#: CY2023 comment 8U6AFHTJ (AMA RUC), as posted on CMS-2022-0113
RUC_TEMPLATE = (
"VI. Evaluation and Management (E/M) Visits [FR section II.F.] The RUC commends "
"the Agency for accepting the work RVU recommendations for the hospital inpatient "
"or observation codes, nursing facility codes, home or residence visit codes, "
"emergency department visits and prolonged services codes. This was the result "
"of significant collaboration by an AMA-convened workgroup that brought together "
"more than 30 specialty societies involved in surveying these services."
)
def test_r67a_coding_by_family_code_and_valuation_by_phrase():
"""R67a, real CY2023 text: KUAPZ8ER's coding support names 99341\u201499350
and "coding changes" (no R65 coding keyword); the RUC's valuation support
names the family only as "home or residence visit codes". Both are kept."""
from pfs.stance import event_subjects
sub = event_subjects(FAM, SUBJ_EVENTS)
val, coding = SUBJ_EVENTS[0].event_key, SUBJ_EVENTS[1].event_key
q = (
"BJC strongly supports this proposal both because it will likely increase "
"our assigned beneficiary population"
)
kept = _rows([pos(coding, "support", "r", q)], KUAPZ8ER_CODING, sub)
assert [(r.event_key, r.stance) for r in kept] == [(coding, "support")]
# the window, not only the quote, carries the family code and the keyword
assert not sub[coding].named_in(q)
q2 = (
"The RUC commends the Agency for accepting the work RVU recommendations for "
"the hospital inpatient or observation codes, nursing facility codes, home or "
"residence visit codes"
)
kept2 = _rows([pos(val, "support", "r", q2)], RUC_TEMPLATE, sub)
assert [(r.event_key, r.stance) for r in kept2] == [(val, "support")]
# the same sentence without the family names no family
other_fam = RUC_TEMPLATE.replace("home or residence visit codes, ", "")
q3 = q2.replace(", home or residence visit codes", "")
assert _rows([pos(val, "support", "r", q3)], other_fam, sub) == []
def test_holding_sentence():
from pfs.stance import _holding_sentence
text = "One is here. Two is here. Three is here. Four is here. Five is here."
assert _holding_sentence(text, "Three is here.", 0) == " Three is here."
assert (
_holding_sentence(text, "Three is here.", 1)
== " Two is here. Three is here. Four is here."
)
assert _holding_sentence(text, "One is here.", 1) == "One is here. Two is here."
assert _holding_sentence(text, "Five is here.", 1) == " Four is here. Five is here."
assert _holding_sentence(text, "absent words", 1) == "absent words"
assert _holding_sentence("no end", "no end", 1) == "no end"
def _rows(raw, text, subjects):
return position_rows(
raw,
text,
family="F",
item_key="K",
docket="D",
extractor="x",
subjects=subjects,
)
def test_position_kept_only_if_quote_or_neighbours_name_the_subject():
from pfs.stance import event_subjects
sub = event_subjects(FAM, SUBJ_EVENTS)
tele, val = SUBJ_EVENTS[2].event_key, SUBJ_EVENTS[0].event_key
text = (
"Telehealth flexibilities for home visit codes matter to our patients. We "
"recommend that all codes listed above should be retained through the end of "
"the year. Unrelated closing words about something else entirely follow in "
"this sentence here."
)
q = "We recommend that all codes listed above should be retained through the end"
kept = _rows([pos(tele, "modify", "r", q)], text, sub)
assert [r.event_key for r in kept] == [tele] # the previous sentence names it
assert _rows([pos(val, "modify", "r", q)], text, sub) == [] # not about RVUs
assert len(_rows([pos(val, "modify", "r", q)], text, None)) == 1 # no filter
# an irrelevant first position does not block a later one on the same event
two = _rows([pos(val, "modify", "r", q), pos(tele, "support", "r", q)], text, sub)
assert [(r.event_key, r.stance) for r in two] == [(tele, "support")]
def test_stances_for_applies_subjects(tmp_path, events):
from pfs.stance import event_subjects
ask = Ask([pos(events[0].event_key, "support", "r", EV_VAL)])
sub = event_subjects(FAM, events) # valuation: names RVU and 99342
(rows, _), _ = _run(
tmp_path, ask, [_commenter("K1")], {"K1": TEXT}, events, subjects=sub
)
assert len(rows) == 1
off = {events[0].event_key: Subject(re.compile("zzzz"))}
(rows, _), _ = _run(
tmp_path, ask, [_commenter("K1")], {"K1": TEXT}, events, subjects=off
)
assert rows == [] # same ledger entry, replayed through a different filter
assert len(ask.calls) == 1
# ── stance-v5: guideline facts, coding event rule, link kinds (R69, R74) ──
def _gf(code, year, fact, value, loc="p. 9"):
from pfs.codetables import GuidelineFactRow
return GuidelineFactRow(
"HOME-VISITS", code, year, fact, value, "B", loc, "cpt-book"
)
V5_FACTS = [
_gf("99341", 2022, "selection_basis", "history_exam_mdm"),
_gf("99341", 2024, "selection_basis", "mdm_or_time"),
_gf("99341", 2024, "time_threshold_min", "15"),
_gf("99343", 2024, "deleted_in_edition", "2022"),
_gf("99341-99350", 2024, "scope_point", "Care is at the residence.", "p. 28"),
]
def test_generations_and_versions():
from pfs.stance import GENERATIONS, guideline_text, prompt_version
assert GENERATIONS == ("stance-v4", "stance-v5", "stance-v6")
assert prompt_version("multi", "stance-v5") == "stance-v5"
assert prompt_version("per-event", "stance-v5") == "stance-v5-per-event"
assert prompt_version("multi", "stance-v4") == "stance-v4"
assert guideline_text("stance-v5") and not guideline_text("stance-v4")
def test_v5_coding_event_states_cpt_facts(con):
from pfs.codetables import write_guideline_facts
write_guideline_facts(con, "HOME-VISITS", V5_FACTS)
ev = docket_events(con, FAM, 2023, guidelines=True)
coding = _by_slug(ev, "coding")
assert "CPT books on file, 2022 and 2024" in coding
assert (
"99341 is selected by level of medical decision making or total time" in coding
)
assert "total-time thresholds 99341 15 minutes" in coding
assert "Care is at the residence. (CPT 2024, p. 28)" in coding
# v4 behaviour is unchanged: no facts text
assert "CPT books" not in " ".join(
e.text for e in docket_events(con, FAM, 2023, guidelines=False)
)
def test_v5_coding_event_needs_facts_not_keywords(con):
s, _ = _store(
"CY 2030 Payment Policies Under the Physician Fee Schedule; Proposed Rule",
{1: "New guideline for 99341 visits."},
)
# keywords alone: v4 makes an event, v5 does not
assert [
e.event_key.rsplit(":", 1)[-1]
for e in docket_events(con, FAM, 2030, s, guidelines=True)
] == ["other"]
assert docket_events(con, FAM, 2030, s, guidelines=False)[0].event_key.endswith(
":coding"
)
# a lineage event that year makes one, and cites the paragraph too
write_events(con, "99342", [_event("99342", 2030, "created")])
ev = docket_events(con, FAM, 2030, s, guidelines=True)
assert ev[0].event_key.endswith(":coding")
assert "1 created" in ev[0].text and "[¶1]" in ev[0].text
s.close()
def test_v5_coding_event_from_facts_alone(con):
from pfs.codetables import write_guideline_facts
write_guideline_facts(con, "HOME-VISITS", V5_FACTS[:2])
ev = docket_events(con, FAM, 2023, guidelines=True)
assert ev[0].event_key.endswith(":coding")
assert "CPT books on file, 2022 and 2024" in ev[0].text
def test_facts_table_missing_is_tolerated(con):
con.execute("DROP TABLE pfs.code_guideline_fact")
got = [
e.event_key.rsplit(":", 1)[-1]
for e in docket_events(con, FAM, 2030, guidelines=True)
]
assert got == ["other"]
def test_missing_facts_error_other_than_table_propagates(con, monkeypatch):
import pfs.stance as st
def boom(c, f):
raise RuntimeError("disk gone")
monkeypatch.setattr(st, "read_guideline_facts", boom)
with pytest.raises(RuntimeError):
docket_events(con, FAM, 2023, guidelines=True)
def test_link_relevance_words_depend_on_kind():
from pfs.stance import event_subjects
fam = dataclasses.replace(
FAM,
links=tuple(ln for ln in FAM.links if ln.target in ("G2211", "B1", "G0318")),
)
evs = [
EventChoice(f"HOME-VISITS:2026:{s}", s, 2026) for s in ("G2211", "B1", "G0318")
]
sub = event_subjects(fam, evs, by_kind=True)
g2211, b1, g0318 = (sub[e.event_key] for e in evs)
assert g2211.named_in("G2211 applies to home or residence visits")
assert g2211.named_in("the add-on for home or residence visits")
assert g2211.named_in("longitudinal home visit codes")
assert g2211.named_in("visit complexity in the home visit codes")
assert not g2211.named_in("prolonged home or residence visit time")
assert b1.named_in("the modifier for home or residence visits")
assert b1.named_in("B1 for 99341")
assert not b1.named_in("prolonged home or residence visit time")
assert g0318.named_in("prolonged home or residence visit time")
assert not g0318.named_in("the add-on for home or residence visits")
# v4 used "prolonged" for every link
old = event_subjects(fam, evs, by_kind=False)
assert old[evs[0].event_key].named_in("prolonged home or residence visit time")
# the shipped default follows the shipped generation
from pfs.stance import guideline_text
dflt = event_subjects(fam, evs)
assert (
dflt[evs[0].event_key].named_in("prolonged home visit codes")
!= guideline_text()
)
# ── stance-v6: telehealth needs lineage, paragraph order, sentence cuts (R78) ──
def test_v6_generation_flags():
from pfs.stance import fixed_text, guideline_text
assert guideline_text("stance-v6") and guideline_text("stance-v5")
assert fixed_text("stance-v6") and not fixed_text("stance-v5")
assert not fixed_text("stance-v4")
def test_v6_telehealth_event_needs_lineage_not_a_paragraph(con):
s, _ = _store(
"CY 2030 Payment Policies Under the Physician Fee Schedule; Proposed Rule",
{1: "We add 99341 as a base code billed with GADV1 to the telehealth list."},
)
slugs = lambda evs: [e.event_key.rsplit(":", 1)[-1] for e in evs] # noqa: E731
assert "telehealth" not in slugs(
docket_events(con, FAM, 2030, s, guidelines=True, fixed=True)
)
assert "telehealth" in slugs(
docket_events(con, FAM, 2030, s, guidelines=False, fixed=False)
)
# a lineage telehealth_list event for a family code makes one
write_events(con, "99342", [_event("99342", 2030, "telehealth_list")])
ev = docket_events(con, FAM, 2030, s, guidelines=True, fixed=True)
assert "(lineage: telehealth list change for 99342)" in _by_slug(ev, "telehealth")
s.close()
def test_v6_telehealth_event_for_a_link_offered_that_year(con):
write_events(con, "G0318", [_event("G0318", 2023, "telehealth_list")])
ev = docket_events(con, FAM, 2023, guidelines=True, fixed=True)
assert "telehealth" in [e.event_key.rsplit(":", 1)[-1] for e in ev]
# a link not offered that year does not count
write_events(con, "G2211", [_event("G2211", 2023, "telehealth_list")])
con.execute("DELETE FROM pfs.code_event WHERE code = 'G0318'")
ev = docket_events(con, FAM, 2023, guidelines=True, fixed=True)
assert "telehealth" not in [e.event_key.rsplit(":", 1)[-1] for e in ev]
LONG_A = (
"Zed is new for 99341. "
+ "Filler words continue here. " * 12
+ "The guideline for 99341 changed."
)
def test_v6_paragraphs_in_pid_order_cut_at_sentences(con):
long_p = (
"The guideline for 99342 changed today. "
+ "Quux filler sentence goes on. " * 30
)
s, _ = _store(
"CY 2030 Payment Policies Under the Physician Fee Schedule; Proposed Rule",
{
5: "The guideline for 99341 and the guideline for 99341 changed again.",
9: "Deleted guideline for 99343 deleted.",
12: long_p,
},
)
write_events(con, "99342", [_event("99342", 2030, "created")])
coding = _by_slug(
docket_events(con, FAM, 2030, s, guidelines=True, fixed=True), "coding"
)
assert coding.index("[¶5]") < coding.index("[¶9]") # p_id order, not density
assert "[¶12]" not in coding # only two paragraphs
old = _by_slug(
docket_events(con, FAM, 2030, s, guidelines=False, fixed=False), "coding"
)
assert old.index("[¶9]") < old.index("[¶5]") # v4: densest first
s.close()
def test_excerpt_sentences_never_cut_mid_sentence():
from pfs.stance import _CODING_WORDS, _excerpt
text = "First sentence about the guideline. " + "Plain filler sentence here. " * 30
out = _excerpt(text, _CODING_WORDS, sentences=True)
assert out.endswith(".") and len(out) <= 600
assert out.startswith("First sentence about the guideline.")
# the run with the most hits wins
text2 = (
"Plain filler sentence here. " * 25
+ "The guideline guideline guideline text ends here."
)
assert _excerpt(text2, _CODING_WORDS, sentences=True).endswith("text ends here.")
# a short paragraph is whole
assert _excerpt("Short one.", _CODING_WORDS, sentences=True) == "Short one."
# a single sentence longer than the limit is cut at a word
one = "guideline " * 100
assert len(_excerpt(one, _CODING_WORDS, sentences=True)) <= 600
# ── multi-docket golden files (R78d) ─────────────────────────────────
def test_golden_dockets_single_and_multi():
from pfs.stance import golden_dockets, item_dockets
single = {"family": "F", "docket": "D1", "items": [{"item_key": "K1", "cells": {}}]}
assert golden_dockets(single) == ["D1"] and item_dockets(single) == {"K1": "D1"}
multi = {
"family": "F",
"dockets": ["D1", "D2"],
"items": [
{"item_key": "K1", "docket": "D1"},
{"item_key": "K2", "docket": "D2"},
],
}
assert golden_dockets(multi) == ["D1", "D2"]
assert item_dockets(multi) == {"K1": "D1", "K2": "D2"}
bad = {**multi, "items": [{"item_key": "K3"}]}
with pytest.raises(ValueError, match="K3"):
item_dockets(bad)
bad = {**multi, "items": [{"item_key": "K3", "docket": "D9"}]}
with pytest.raises(ValueError, match="D9"):
item_dockets(bad)
def test_eval_record_names_every_docket():
from pfs.stance import eval_record, score
golden = {
"family": "F",
"dockets": ["D1", "D2"],
"items": [
{
"item_key": "K1",
"docket": "D1",
"cells": {"F:2023:x": {"label": "support"}},
},
{
"item_key": "K2",
"docket": "D2",
"cells": {"F:2026:x": {"label": "oppose"}},
},
],
}
rows = [
StanceRow("F", "K1", "D1", "F:2023:x", "support", "", "", "m"),
StanceRow("F", "K2", "D2", "F:2026:x", "oppose", "", "", "m"),
]
rec = eval_record(golden, score(golden, rows))
assert rec["dockets"] == ["D1", "D2"] and rec["docket"] == "D1; D2"
assert (rec["recall"], rec["direction"], rec["n_items"]) == (1.0, 1.0, 2)