Files
stack/tests/pfs/test_reaction.py
kert 8e7d3637f7 fix(pfs): fr-pairs n_total is the rule's total Comment: count (refs #690)
reaction.fr_pairs previously reported n_total equal to n_items (the
matching Response: count, always the same number as the family's pair
count) — it now reports the rule item's TOTAL Comment: paragraph count,
family or not, matching the n_items-out-of-n_total shape the docket
rows already use. _COMMENT_RE/_RESPONSE_RE are tightened to no leading
whitespace and no space before the colon, so they agree exactly with
the LIKE 'Comment:%'/'Response:%' SQL prefilter.

Also moves the code-pattern/code-match helpers pfs.guidance and
pfs.reaction both need (code_pattern/codes_in) into pfs.families next
to find_codes/FR_CITE_RE, as public functions both modules import
instead of reaction reaching into guidance's private names.
2026-09-10 00:56:21 -04:00

390 lines
14 KiB
Python

"""pfs.reaction — public-reaction series per code family (#690, task 7)."""
from __future__ import annotations
import json
from unittest.mock import MagicMock
import duckdb
import pytest
from bib.dockets import Docket
from bib.item import Rule
from bib.store import Store
from pfs.codetables import ReactionRow, ensure_tables, read_reaction, write_reaction
from pfs.reaction import (
STANCES,
classify_stances,
docket_counts,
fr_pairs,
series,
stance_sample,
)
# ── fake pgvector engine ──────────────────────────────────────────────
def _engine(rows):
engine = MagicMock()
conn = engine.begin.return_value.__enter__.return_value
conn.execute.return_value.fetchall.return_value = rows
return engine, conn
def _multi_engine(docket_rows=None, stance_rows=None):
"""A fake engine whose rows depend on which of the two queries is
running — distinguished by the ``docket``/``n`` bind params that
only ``_STANCE_SAMPLE_SQL`` carries."""
engine = MagicMock()
conn = engine.begin.return_value.__enter__.return_value
def _execute(_sql, params):
result = MagicMock()
if "docket" in params:
result.fetchall.return_value = stance_rows or []
else:
result.fetchall.return_value = docket_rows or []
return result
conn.execute.side_effect = _execute
return engine, conn
class TestDocketCounts:
def test_maps_rows_and_binds_params(self):
engine, conn = _engine([("CMS-2023-0121", 40, 200), ("CMS-2024-0453", 3, 150)])
out = docket_counts(engine, "CCM", ("99490", "99439"))
assert out == [("CMS-2023-0121", 40, 200), ("CMS-2024-0453", 3, 150)]
params = conn.execute.call_args.args[1]
assert params["codes"] == ["99490", "99439"]
assert params["family_key"] == "CCM"
assert params["collection"] == "comments"
def test_null_counts_become_zero(self):
engine, _conn = _engine([("CMS-2023-0121", None, 0)])
assert docket_counts(engine, "CCM", ()) == [("CMS-2023-0121", 0, 0)]
class TestStanceSample:
def test_maps_rows_and_binds_params(self):
engine, conn = _engine([("C1", "chunk one"), ("C2", "chunk two")])
out = stance_sample(engine, "CCM", ("99490",), "CMS-2023-0121", n=5)
assert out == [("C1", "chunk one"), ("C2", "chunk two")]
params = conn.execute.call_args.args[1]
assert params["docket"] == "CMS-2023-0121"
assert params["n"] == 5
assert params["family_key"] == "CCM"
def test_n_zero_short_circuits_without_a_query(self):
engine, conn = _engine([("C1", "x")])
assert stance_sample(engine, "CCM", ("99490",), "CMS-2023-0121", n=0) == []
conn.execute.assert_not_called()
class TestClassifyStances:
def test_tallies_known_slugs(self):
sample = [("C1", "t1"), ("C2", "t2"), ("C3", "t3")]
answers = {"t1": "support", "t2": "oppose", "t3": "support"}
counts = classify_stances(sample, lambda text, choices: answers[text])
assert counts["support"] == 2 and counts["oppose"] == 1
def test_out_of_vocabulary_answer_counts_as_unclear(self):
sample = [("C1", "t1")]
counts = classify_stances(sample, lambda text, choices: "maybe-ish")
assert counts["unclear"] == 1
def test_none_answer_counts_as_unclear(self):
sample = [("C1", "t1")]
counts = classify_stances(sample, lambda text, choices: None)
assert counts["unclear"] == 1
def test_every_stance_is_a_known_slug(self):
assert set(STANCES) == {"support", "oppose", "modify", "unclear"}
# ── fr_pairs ────────────────────────────────────────────────────────
@pytest.fixture
def store_with_pairs():
s = Store(":memory:")
item_key = s.create(Rule(title="CY2015 PFS final rule"))
con = s._con() # noqa: SLF001
con.executemany(
"INSERT INTO fr_anchors (item_key, p_id, page, ordinal, text) VALUES (?,?,?,?,?)",
[
(
item_key,
10,
100,
1,
"Comment: Commenters asked why CPT code 99490 (chronic care "
"management) is valued as proposed.",
),
(
item_key,
11,
100,
2,
"Response: We are finalizing the RVUs for 99490 as proposed.",
),
(item_key, 12, 101, 3, "Comment: A commenter raised an unrelated point."),
(item_key, 13, 101, 4, "Response: We disagree with the commenter."),
# a Comment: with no immediately-following Response: (next para
# is prose) must never be counted.
(item_key, 14, 102, 5, "Comment: Another commenter mentioned 99490."),
(item_key, 15, 102, 6, "We did not adopt this suggestion."),
],
)
con.commit()
yield s, item_key
s.close()
@pytest.fixture
def store_with_continuation_pair():
"""Ruling A12: a Comment: paragraph with no code of its own, a
continuation paragraph (no Comment:/Response: prefix) naming a code,
then the Response: — must still count as one pair."""
s = Store(":memory:")
item_key = s.create(Rule(title="CY2016 PFS final rule"))
con = s._con() # noqa: SLF001
con.executemany(
"INSERT INTO fr_anchors (item_key, p_id, page, ordinal, text) VALUES (?,?,?,?,?)",
[
(
item_key,
20,
200,
1,
"Comment: Commenters raised concerns about staff time requirements.",
),
(
item_key,
21,
200,
2,
"Providers noted that 99490 requires clinical staff supervision "
"that is hard to bill separately.",
),
(
item_key,
22,
200,
3,
"Response: We agree and are clarifying supervision requirements.",
),
],
)
con.commit()
yield s, item_key
s.close()
class TestFrPairs:
def test_counts_only_pairs_naming_a_code(self, store_with_pairs):
# n_total is the item's TOTAL Comment: paragraph count (family or
# not) — store_with_pairs has three: ¶10, ¶12, ¶14.
s, item_key = store_with_pairs
out = fr_pairs(s, ("99490",))
assert out == [(item_key, 2015, 1, 3)]
def test_no_codes_returns_empty(self, store_with_pairs):
s, _item_key = store_with_pairs
assert fr_pairs(s, ()) == []
def test_unmatched_code_yields_no_rows(self, store_with_pairs):
s, _item_key = store_with_pairs
assert fr_pairs(s, ("99437",)) == []
def test_dangling_comment_without_response_is_not_counted(self, store_with_pairs):
# ¶14's "Comment:" is followed by ¶15, which is not a "Response:"
# paragraph — it must not inflate the count even though it names
# 99490.
s, item_key = store_with_pairs
out = fr_pairs(s, ("99490",))
assert out == [(item_key, 2015, 1, 3)]
def test_code_named_only_in_a_continuation_paragraph_still_pairs(
self, store_with_continuation_pair
):
# Ruling A12: intervening continuation paragraphs are allowed
# between Comment: and Response:, and the code match runs over
# the whole span — neither the Comment: nor the Response:
# paragraph names 99490 here, only the continuation between them.
# This item has one Comment: paragraph total, so n_total is 1.
s, item_key = store_with_continuation_pair
out = fr_pairs(s, ("99490",))
assert out == [(item_key, 2016, 1, 1)]
def test_new_comment_abandons_an_unresolved_open_one(self):
# A Comment: immediately followed by another Comment: (no
# Response: in between) leaves the first one unpaired; the
# second one, properly followed by a Response:, still counts.
s = Store(":memory:")
item_key = s.create(Rule(title="CY2017 PFS final rule"))
con = s._con() # noqa: SLF001
con.executemany(
"INSERT INTO fr_anchors (item_key, p_id, page, ordinal, text) "
"VALUES (?,?,?,?,?)",
[
(item_key, 1, 1, 1, "Comment: A commenter mentioned 99490 in passing."),
(item_key, 2, 1, 2, "Comment: A second, unrelated 99490 comment."),
(item_key, 3, 1, 3, "Response: We are finalizing 99490 as proposed."),
],
)
con.commit()
try:
out = fr_pairs(s, ("99490",))
finally:
s.close()
# Two Comment: paragraphs total in this item (¶1 abandoned, ¶2
# paired), so n_total is 2 even though only one pair qualifies.
assert out == [(item_key, 2017, 1, 2)]
# ── series ──────────────────────────────────────────────────────────
class TestSeries:
@pytest.fixture
def store(self, store_with_pairs):
s, item_key = store_with_pairs
s.docket_upsert(
Docket(id="CMS-2023-0121", rule_cms_id="R1", comment_end_date="2023-06-01")
)
return s, item_key
def test_docket_and_fr_pairs_rows_without_stance(self, store):
s, item_key = store
engine, _conn = _multi_engine(docket_rows=[("CMS-2023-0121", 5, 20)])
rows = series(engine, s, "CCM", ("99490",))
by_kind = {r.period_kind: r for r in rows}
docket_row = by_kind["docket"]
assert (
docket_row.period,
docket_row.year,
docket_row.n_items,
docket_row.n_total,
) == (
"CMS-2023-0121",
2023,
5,
20,
)
assert docket_row.stance_support == 0 and docket_row.sample_json == "[]"
fr_row = by_kind["fr-pairs"]
assert (fr_row.period, fr_row.year, fr_row.n_items, fr_row.n_total) == (
item_key,
2015,
1,
3,
)
def test_stance_sample_classifies_and_fills_sample_json(self, store):
s, _item_key = store
engine, _conn = _multi_engine(
docket_rows=[("CMS-2023-0121", 5, 20)],
stance_rows=[("C1", "we support this"), ("C2", "we oppose this")],
)
def fake_classify(text, choices):
return "support" if "support" in text else "oppose"
rows = series(
engine, s, "CCM", ("99490",), classify=fake_classify, stance_sample_n=2
)
docket_row = next(r for r in rows if r.period_kind == "docket")
assert docket_row.stance_support == 1 and docket_row.stance_oppose == 1
sample = json.loads(docket_row.sample_json)
assert {s_["item_key"] for s_ in sample} == {"C1", "C2"}
assert {s_["stance"] for s_ in sample} == {"support", "oppose"}
def test_name_overrides_family_key_in_the_stance_prompt(self, store):
s, _item_key = store
engine, _conn = _multi_engine(
docket_rows=[("CMS-2023-0121", 5, 20)],
stance_rows=[("C1", "a comment")],
)
seen_prompts = []
def fake_classify(text, choices):
seen_prompts.append(text)
return "support"
series(
engine,
s,
"CCM",
("99490",),
classify=fake_classify,
stance_sample_n=1,
name="Chronic Care Management",
)
assert len(seen_prompts) == 1
assert "Chronic Care Management" in seen_prompts[0]
assert "CCM" not in seen_prompts[0]
def test_zero_stance_sample_skips_classification(self, store):
s, _item_key = store
engine, conn = _multi_engine(docket_rows=[("CMS-2023-0121", 5, 20)])
rows = series(
engine,
s,
"CCM",
("99490",),
classify=lambda t, c: "support",
stance_sample_n=0,
)
docket_row = next(r for r in rows if r.period_kind == "docket")
assert docket_row.stance_support == 0
# only the docket_counts query ran — no stance_sample round trip
assert conn.execute.call_count == 1
def test_code_pseudo_family(self, store):
s, _item_key = store
engine, conn = _multi_engine(docket_rows=[("CMS-2023-0121", 2, 20)])
rows = series(engine, s, "G2211", ("G2211",))
assert [r.family for r in rows if r.period_kind == "docket"] == ["G2211"]
params = conn.execute.call_args.args[1]
assert params["codes"] == ["G2211"]
# ── DDL / write / read round trip ────────────────────────────────────
@pytest.fixture
def con():
c = duckdb.connect(":memory:")
ensure_tables(c)
yield c
c.close()
class TestReactionTable:
def _row(
self, family="CCM", period="CMS-2023-0121", period_kind="docket", year=2023
):
return ReactionRow(family, period, period_kind, year, 5, 20, 1, 0, 0, 1, "[]")
def test_write_then_read_round_trips(self, con):
rows = [self._row(), self._row(period="R1", period_kind="fr-pairs", year=2015)]
n = write_reaction(con, "CCM", rows)
assert n == 2
got = read_reaction(con, "CCM")
assert {(r.period, r.period_kind) for r in got} == {
("CMS-2023-0121", "docket"),
("R1", "fr-pairs"),
}
def test_write_is_delete_then_insert_per_family(self, con):
write_reaction(con, "CCM", [self._row(period="CMS-2023-0121")])
write_reaction(con, "CCM", [self._row(period="CMS-2024-0453")])
got = read_reaction(con, "CCM")
assert [r.period for r in got] == ["CMS-2024-0453"]
def test_other_family_untouched(self, con):
write_reaction(con, "G2211", [self._row(family="G2211")])
write_reaction(con, "CCM", [])
assert len(read_reaction(con, "G2211")) == 1