The grounding guard checks each paraphrase against its numbered sentence (content-word coverage, unseen words, code ranges per action clause, NFKC copy guard). Deletions cite CPT Changes 2023 pages; the Panel row is built from the entry's code lists. Pair phrase: drop the bare 'home and residence'. stance-v6 is not shipped (golden recall 11/27 vs v5 14/27); v5 stays the default.
242 lines
8.4 KiB
Python
242 lines
8.4 KiB
Python
"""pfs.curated — the hand-curated, cited family registry."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from pfs.codetables import FamilyRow
|
|
from pfs.curated import apply_curated, curated_rows, load_curated
|
|
|
|
|
|
def _write(tmp_path, body):
|
|
p = tmp_path / "c.yaml"
|
|
p.write_text(body)
|
|
return p
|
|
|
|
|
|
def test_packaged_home_visits_loads():
|
|
fam = load_curated()["HOME-VISITS"]
|
|
assert len(fam.codes) == 20 and "99343" in fam.codes and "G2211" not in fam.codes
|
|
assert {(link.kind, link.target) for link in fam.links} >= {
|
|
("add-on", "G2211"),
|
|
("modifier", "B1"),
|
|
("modifier", "B2"),
|
|
("prolonged-add-on", "G0318"),
|
|
}
|
|
m = next(m for m in fam.members if m.code == "99343")
|
|
assert (m.role, m.until, m.item_key, m.p_id) == (
|
|
"predecessor",
|
|
2022,
|
|
"PETM225D",
|
|
1672,
|
|
)
|
|
g = next(link for link in fam.links if link.target == "G0318")
|
|
assert (g.item_key, g.p_id, g.since) == ("PETM225D", 1694, 2023)
|
|
old = next(m for m in fam.members if m.code == "99339")
|
|
assert (old.item_key, old.p_id, old.rvu_year) == ("", 0, 2022)
|
|
|
|
|
|
def test_page_cite_and_key_normalised(tmp_path):
|
|
p = _write(
|
|
tmp_path,
|
|
"fam-x:\n name: X\n synonyms: [' Foo Bar ']\n members:\n"
|
|
" - {code: g0318, role: member, cite: {item_key: K, page: 7}}\n",
|
|
)
|
|
fam = load_curated(p)["FAM-X"]
|
|
assert fam.synonyms == ("foo bar",)
|
|
m = fam.members[0]
|
|
assert (m.code, m.page, m.p_id, m.since, m.until) == ("G0318", 7, 0, None, None)
|
|
assert fam.links == ()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"member, needle",
|
|
[
|
|
("{code: '99341', role: base}", "missing cite"),
|
|
("{code: '99341', role: base, cite: {item_key: K}}", "ambiguous cite"),
|
|
(
|
|
"{code: '99341', role: base, cite: {item_key: K, p_id: 1, page: 2}}",
|
|
"ambiguous",
|
|
),
|
|
("{code: '99341', role: bogus, cite: {rvu_last_year: 2022}}", "unknown role"),
|
|
("{code: 'ABC', role: base, cite: {rvu_last_year: 2022}}", "not a CPT"),
|
|
],
|
|
)
|
|
def test_bad_member_rejected(tmp_path, member, needle):
|
|
p = _write(tmp_path, f"F:\n name: F\n members:\n - {member}\n")
|
|
with pytest.raises(ValueError, match=needle) as e:
|
|
load_curated(p)
|
|
assert "F member" in str(e.value)
|
|
|
|
|
|
def test_bad_link_rejected(tmp_path):
|
|
p = _write(
|
|
tmp_path,
|
|
"F:\n name: F\n links:\n - {kind: nope, target: B1, cite: {rvu_last_year: 1}}\n",
|
|
)
|
|
with pytest.raises(ValueError, match="F link .B1.: unknown kind"):
|
|
load_curated(p)
|
|
|
|
|
|
def test_empty_file_is_empty(tmp_path):
|
|
assert load_curated(_write(tmp_path, "")) == {}
|
|
|
|
|
|
def test_curated_rows_carry_cite():
|
|
rows = curated_rows(load_curated())
|
|
r = next(r for r in rows if r.code == "99343")
|
|
assert (r.key, r.role, r.until, r.item_key, r.p_id, r.note) == (
|
|
"HOME-VISITS",
|
|
"predecessor",
|
|
2022,
|
|
"PETM225D",
|
|
1672,
|
|
"",
|
|
)
|
|
assert len(rows) == 20
|
|
|
|
|
|
def test_apply_curated_claims_codes():
|
|
derived = [
|
|
FamilyRow(
|
|
"NEW-PATIENT",
|
|
"New Patient",
|
|
"99341",
|
|
"base",
|
|
2015,
|
|
None,
|
|
"",
|
|
0,
|
|
"E/M > Home",
|
|
),
|
|
FamilyRow(
|
|
"NEW-PATIENT", "New Patient", "99202", "base", 2015, None, "", 0, "E/M"
|
|
),
|
|
]
|
|
rows = apply_curated(derived, load_curated())
|
|
assert [r.key for r in rows if r.code == "99341"] == ["HOME-VISITS"]
|
|
assert [r.key for r in rows if r.code == "99202"] == ["NEW-PATIENT"]
|
|
assert rows == sorted(rows, key=lambda r: (r.key, r.code))
|
|
|
|
|
|
_OK = "cite: {item_key: K, p_id: 1}"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"body, needle",
|
|
[
|
|
("F:\n members: []\n", "F: missing name"),
|
|
("F: [1]\n", "F: family must be a mapping"),
|
|
("F:\n name: F\n members: 3\n", "F: members must be a list"),
|
|
("F:\n name: F\n members: [oops]\n", "F members entry 'oops'"),
|
|
("F:\n name: F\n links: [oops]\n", "F links entry 'oops'"),
|
|
(
|
|
"F:\n name: F\n members:\n - {code: '99341', role: base, cite: {item_key: K, p_id: x}}\n",
|
|
"F member .99341.: p_id must be an integer",
|
|
),
|
|
(
|
|
"F:\n name: F\n members:\n - {code: '99341', role: base, cite: {item_key: K, page: x}}\n",
|
|
"page must be an integer",
|
|
),
|
|
(
|
|
"F:\n name: F\n members:\n - {code: '99341', role: base, cite: {rvu_last_year: x}}\n",
|
|
"rvu_last_year must be an integer",
|
|
),
|
|
(
|
|
f"F:\n name: F\n members:\n - {{code: '99341', role: base, since: abc, {_OK}}}\n",
|
|
"F member .99341.: since must be an integer",
|
|
),
|
|
(
|
|
f"F:\n name: F\n members:\n - {{code: '99341', role: base, until: true, {_OK}}}\n",
|
|
"until must be an integer",
|
|
),
|
|
(
|
|
f"F:\n name: F\n members:\n - {{code: '99341', role: base, {_OK}}}\n - {{code: '99341', role: base, {_OK}}}\n",
|
|
"F member .99341.: duplicate member code",
|
|
),
|
|
(
|
|
"F:\n name: F\n members:\n - {code: '99341', role: base, cite: oops}\n",
|
|
"F member .99341.: cite must be a mapping",
|
|
),
|
|
(
|
|
f"F:\n name: F\n links:\n - {{kind: add-on, target: G1, since: x, {_OK}}}\n",
|
|
"F link .G1.: since must be an integer",
|
|
),
|
|
(
|
|
f"A:\n name: A\n members:\n - {{code: '99341', role: base, {_OK}}}\n"
|
|
f"B:\n name: B\n members:\n - {{code: '99341', role: base, {_OK}}}\n",
|
|
"B member 99341: already a member of A",
|
|
),
|
|
],
|
|
)
|
|
def test_malformed_entries_named(tmp_path, body, needle):
|
|
with pytest.raises(ValueError, match=needle):
|
|
load_curated(_write(tmp_path, body))
|
|
|
|
|
|
def test_pair_phrases_absent_present_and_normalised(tmp_path):
|
|
absent = load_curated(_write(tmp_path, "F:\n name: F\n"))["F"]
|
|
assert absent.pair_phrases == ()
|
|
p = _write(tmp_path, "F:\n name: F\n pair_phrases: [' Home Visit ', '']\n")
|
|
assert load_curated(p)["F"].pair_phrases == ("home visit",)
|
|
|
|
|
|
@pytest.mark.parametrize("value", ["home visit", "[1, 2]", "{a: b}"])
|
|
def test_pair_phrases_wrong_type_rejected(tmp_path, value):
|
|
p = _write(tmp_path, f"F:\n name: F\n pair_phrases: {value}\n")
|
|
with pytest.raises(ValueError, match="F: pair_phrases must be a list of strings"):
|
|
load_curated(p)
|
|
|
|
|
|
def test_home_visits_pair_phrases_are_visit_naming():
|
|
fam = load_curated()["HOME-VISITS"]
|
|
assert (
|
|
"home visits" not in fam.pair_phrases and "domiciliary" not in fam.pair_phrases
|
|
)
|
|
assert "house call" in fam.pair_phrases
|
|
|
|
|
|
def test_link_aliases_and_their_cite():
|
|
fam = load_curated()["HOME-VISITS"]
|
|
g = next(link for link in fam.links if link.target == "G0318")
|
|
assert (g.aliases, g.alias_item_key, g.alias_p_id) == (("GXXX3",), "MZ24MX5S", 880)
|
|
b = next(link for link in fam.links if link.target == "B1")
|
|
assert (b.aliases, b.alias_item_key, b.alias_p_id) == (("MOD1",), "5ITGVDJV", 521)
|
|
b2 = next(link for link in fam.links if link.target == "B2")
|
|
assert (b2.aliases, b2.alias_item_key, b2.alias_p_id) == (
|
|
("MOD2",),
|
|
"5ITGVDJV",
|
|
537,
|
|
)
|
|
# the bare phrase admitted nursing-facility G2211 letters (R75); e/m stays
|
|
assert "home and residence e/m" in fam.pair_phrases
|
|
assert "home and residence" not in fam.pair_phrases
|
|
|
|
|
|
def test_alias_parsing_and_errors(tmp_path):
|
|
head = "F:\n name: F\n links:\n - {kind: add-on, target: G1, since: 2023, "
|
|
ok = (
|
|
head
|
|
+ "aliases: [' gxxx3 ', ''], alias_cite: {item_key: K, p_id: 5}, cite: {item_key: K, p_id: 1}}\n"
|
|
)
|
|
link = load_curated(_write(tmp_path, ok))["F"].links[0]
|
|
assert link.aliases == ("GXXX3",) and link.alias_p_id == 5
|
|
for bad, needle in [
|
|
(
|
|
head + "aliases: GXXX3, cite: {item_key: K, p_id: 1}}\n",
|
|
"aliases must be a list",
|
|
),
|
|
(
|
|
head + "aliases: [1], cite: {item_key: K, p_id: 1}}\n",
|
|
"aliases must be a list",
|
|
),
|
|
(head + "aliases: [X], cite: {item_key: K, p_id: 1}}\n", "alias_cite"),
|
|
(
|
|
head
|
|
+ "aliases: [X], alias_cite: {item_key: K, page: 1}, cite: {item_key: K, p_id: 1}}\n",
|
|
"alias_cite",
|
|
),
|
|
]:
|
|
with pytest.raises(ValueError, match=needle):
|
|
load_curated(_write(tmp_path, bad))
|