feat(pfs): public-reaction series per code family — pfs.code_reaction + stack pfs reaction (refs #690)

This commit is contained in:
kert
2026-09-09 23:54:12 -04:00
parent 839c5bfc2c
commit 5af8c1f08e
5 changed files with 848 additions and 1 deletions

View File

@@ -6,6 +6,7 @@
uv run stack pfs lineage --code G2058 [--write]
uv run stack pfs families [--write]
uv run stack pfs guidance --family CCM [--family …] [--write]
uv run stack pfs reaction --family CCM [--code G2211] [--stance-sample 30] [--write]
uv run stack pfs review [--code G0556]
uv run stack pfs cpt-ingest --edition 2024 [--edition 2022 …] [--dry-run]
uv run stack pfs cpt-ingest --all
@@ -35,12 +36,14 @@ from pfs.codetables import (
write_events,
write_families,
write_guidance,
write_reaction,
)
from pfs.cpt_load import ingest as cpt_ingest
from pfs.extract import extract_code
from pfs.families import FAMILIES, HAND_FAMILIES, derive_families, refresh_from
from pfs.guidance import build as build_guidance
from pfs.lineage import lineage
from pfs.reaction import series as reaction_series
log = logging.getLogger(__name__)
@@ -92,6 +95,13 @@ def _classifier() -> Any:
return closed_vocab_classifier(cfg, HostPool.from_config(cfg))
def _engine() -> Any:
from llm import config as llm_config
from llm.index import _engine as pgvector_engine
return pgvector_engine(llm_config.load())
def _run_elements(
con: Any, store: Any, targets: list[str], classify: Any, *, write: bool
) -> None:
@@ -409,6 +419,128 @@ def guidance(
con.close()
def _print_reaction(rows: list[Any]) -> None:
for r in sorted(rows, key=lambda r: (r.family, r.period_kind, r.year, r.period)):
if r.period_kind == "docket":
stance = ""
if (
r.stance_support
or r.stance_oppose
or r.stance_modify
or r.stance_unclear
):
stance = (
f" stance support={r.stance_support} oppose={r.stance_oppose} "
f"modify={r.stance_modify} unclear={r.stance_unclear}"
)
typer.echo(
f"{r.family} {r.period} ({r.year}) {r.n_items}/{r.n_total} commenters{stance}"
)
else:
typer.echo(
f"{r.family} {r.period} CY{r.year} "
f"{r.n_items} comment/response pairs"
)
def _reaction_targets(
con: Any, family: list[str], code: list[str]
) -> list[tuple[str, tuple[str, ...]]]:
refresh_from(con)
targets: list[tuple[str, tuple[str, ...]]] = []
for f in family:
key = f.upper()
fam = FAMILIES.get(key)
if fam is None:
raise typer.BadParameter(
f"unknown family {key!r}; known: {', '.join(HAND_FAMILIES)}"
)
targets.append((key, fam.codes))
for c in code:
key = c.upper()
targets.append((key, (key,)))
return targets
@app.command()
def reaction(
family: list[str] = typer.Option(
[], "--family", help="Family key (CCM, APCM, …); repeatable."
),
code: list[str] = typer.Option(
[],
"--code",
help="A single code as its own pseudo-family, keyed by the code; repeatable.",
),
stance_sample: int = typer.Option(
0,
"--stance-sample",
help="Classify the newest N commenters per docket into support/oppose/"
"modify/unclear via the self-hosted classifier (0 = skip; no stance "
"sample means no LLM call at all).",
),
write: bool = typer.Option(False, "--write"),
) -> None:
"""Public-reaction series per code family (pfs.code_reaction): comment
counts per docket (share of the docket's commenters mentioning the
family), an optional stance sample, and the FR Comment:/Response:
pair counts per rule as the pre-2017 proxy. Always recomputed live
from pgvector/bib — there is no persisted-table fallback to read
without --write, so --write only adds persisting the result."""
if not family and not code:
raise typer.BadParameter("pass --family or --code (repeatable)")
store = _store()
if write:
# Mirrors `elements --write` (I9): the stance classifier — the
# only genuinely slow part, ~1-2s/sample — runs inside the batch
# here too, the same tradeoff that command already makes.
with _batch() as con:
ensure_tables(con)
targets = _reaction_targets(con, family, code)
classify = _classifier() if stance_sample > 0 else None
engine = _engine()
rows: list[Any] = []
for key, codes in targets:
fam_rows = reaction_series(
engine,
store,
key,
codes,
classify=classify,
stance_sample_n=stance_sample,
)
write_reaction(con, key, fam_rows)
rows.extend(fam_rows)
_publish()
_print_reaction(rows)
return
# No --write: resolving family keys is the only thing that touches
# DuckDB, and it's a plain read (I4) — never the write lock a
# notebook may be holding (#508-#514).
con = _read()
try:
targets = _reaction_targets(con, family, code)
finally:
con.close()
classify = _classifier() if stance_sample > 0 else None
engine = _engine()
rows = []
for key, codes in targets:
rows.extend(
reaction_series(
engine,
store,
key,
codes,
classify=classify,
stance_sample_n=stance_sample,
)
)
_print_reaction(rows)
@app.command()
def review(code: str = typer.Option("", "--code")) -> None:
"""Element lines the classifier could not place."""

View File

@@ -116,6 +116,32 @@ class GuidanceRow:
page_src: int
@dataclass(frozen=True)
class ReactionRow:
"""One period's public-reaction counts for a code family
(``pfs.code_reaction``, #690) — ``period_kind`` is ``"docket"``
(``period`` is a regulations.gov docket id, ``year`` the docket's
``comment_end_date`` year) or ``"fr-pairs"`` (``period`` is the FR
rule ``item_key``, ``year`` its ``rule_year_of``). ``n_items``/
``n_total`` mean distinct commenters mentioning the family / total
distinct commenters for a docket row, or qualifying Comment:/
Response: paragraph pairs for an fr-pairs row. ``stance_*`` and
``sample_json`` (``[{"item_key": …, "stance": …}]``) stay zero/
``"[]"`` unless a stance sample was classified for that docket."""
family: str
period: str
period_kind: str # "docket" | "fr-pairs"
year: int
n_items: int
n_total: int
stance_support: int
stance_oppose: int
stance_modify: int
stance_unclear: int
sample_json: str
@dataclass(frozen=True)
class CptSectionRow:
edition_year: int
@@ -217,6 +243,10 @@ CREATE TABLE IF NOT EXISTS pfs.code_family (
CREATE TABLE IF NOT EXISTS pfs.code_guidance (
family VARCHAR, code VARCHAR, kind VARCHAR, locator VARCHAR, item_key VARCHAR,
item_key_src VARCHAR, p_id_src INTEGER, page_src INTEGER);
CREATE TABLE IF NOT EXISTS pfs.code_reaction (
family VARCHAR, period VARCHAR, period_kind VARCHAR, year INTEGER,
n_items INTEGER, n_total INTEGER, stance_support INTEGER, stance_oppose INTEGER,
stance_modify INTEGER, stance_unclear INTEGER, sample_json VARCHAR);
CREATE TABLE IF NOT EXISTS pfs.cpt_section (
edition_year INTEGER, item_key VARCHAR, sec_id VARCHAR, level INTEGER, title VARCHAR,
path VARCHAR[], path_key VARCHAR, code_lo VARCHAR, code_hi VARCHAR, guideline VARCHAR);
@@ -360,6 +390,21 @@ def read_guidance(con: Any, family: str) -> list[GuidanceRow]:
return [GuidanceRow(*r) for r in rows]
def write_reaction(con: Any, family: str, rows: Sequence[ReactionRow]) -> int:
ensure_tables(con)
con.execute("DELETE FROM pfs.code_reaction WHERE family = ?", [family])
return _insert(con, "pfs.code_reaction", rows)
def read_reaction(con: Any, family: str) -> list[ReactionRow]:
rows = con.execute(
"SELECT * FROM pfs.code_reaction WHERE family = ? "
"ORDER BY period_kind, year, period",
[family],
).fetchall()
return [ReactionRow(*r) for r in rows]
_CPT_TABLES = (
"pfs.cpt_section",
"pfs.cpt_code",

281
src/pfs/reaction.py Normal file
View File

@@ -0,0 +1,281 @@
"""Public-reaction series per code family (``pfs.code_reaction``, #690,
task 7 of the 2026-09-09 code-family-anchors plan).
Two period kinds, one ``ReactionRow`` shape:
* ``"docket"`` — one row per regulations.gov docket (2017-2026, ten
sealed dockets, ``bib.Store.dockets()``): how many distinct
commenters (``item_key``) mentioned the family, out of the docket's
total commenters, straight from the ``comments`` pgvector
collection's chunk metadata (``docket``, ``item_key``, ``codes``,
``families``) — never from ``Docket.counts_json``, which counts raw
comment documents, not distinct commenters who named the family.
When ``--stance-sample N`` is passed, the newest *N* commenters' first
matching chunk is classified into ``STANCES`` by the self-hosted
closed-vocabulary classifier (``llm.classify``); an answer outside
the vocabulary counts as ``"unclear"``.
* ``"fr-pairs"`` — one row per FR rule item, back to 2001: how many
``Comment:``/``Response:`` paragraph pairs in that rule's
``fr_anchors`` name a family code — the pre-2017 proxy, since
regulations.gov comment text itself isn't indexed for rules that old.
``series`` builds both halves for one family (or one ``--code``
pseudo-family, a single-code family keyed by the code itself) and
returns them as one list, dockets first (docket id order) then FR pairs
(rule item_key order); the CLI (``stack pfs reaction``) prints and
optionally writes them.
"""
from __future__ import annotations
import json
import re
from collections import Counter
from typing import Any, Callable, Sequence
from sqlalchemy import text
from pfs.descriptors import rule_year_of
from pfs.guidance import _code_pattern, _codes_in
#: Closed vocabulary the stance classifier answers from — anything else
#: (a refusal, a hedge, garbage) counts as "unclear" (Resolutions).
STANCES = ("support", "oppose", "modify", "unclear")
#: One docket's distinct-commenter count, with vs. without the family —
#: matches by literal code presence (``codes`` metadata) OR family
#: membership (``families`` metadata), the same two-way match
#: ``llm.evidence``'s code-cited/family-cited sources use, so a chunk
#: the indexer only stamped via family membership (no literal code
#: string in that particular chunk) still counts.
_DOCKET_COUNTS_SQL = text(
"SELECT docket, "
"count(DISTINCT item_key) FILTER (WHERE matched) AS n_items, "
"count(DISTINCT item_key) AS n_total "
"FROM ( "
"SELECT e.cmetadata->>'docket' AS docket, e.cmetadata->>'item_key' AS item_key, "
"(string_to_array(COALESCE(e.cmetadata->>'codes', ''), ' ') && CAST(:codes AS text[])) "
"OR (string_to_array(COALESCE(e.cmetadata->>'families', ''), ' ') && ARRAY[:family_key]) "
"AS matched "
"FROM langchain_pg_embedding e "
"WHERE e.collection_id = (SELECT uuid FROM langchain_pg_collection WHERE name = :collection) "
") t "
"WHERE docket IS NOT NULL AND docket <> '' "
"GROUP BY docket ORDER BY docket"
)
#: The newest N distinct items' first matching chunk in one docket — the
#: same one-chunk-per-item windowing ``llm.evidence``'s family-cited SQL
#: uses (stage one: lowest ``seq`` per item_key; stage two: newest
#: ``date`` first), text truncated to 1,500 chars for the classifier
#: (Resolutions).
_STANCE_SAMPLE_SQL = text(
"SELECT item_key, LEFT(document, 1500) FROM ( "
"SELECT e.cmetadata->>'item_key' AS item_key, e.document AS document, "
"e.cmetadata->>'date' AS date, "
"row_number() OVER ( "
"PARTITION BY e.cmetadata->>'item_key' "
"ORDER BY COALESCE(NULLIF(e.cmetadata->>'seq', '')::int, 0) ASC "
") AS item_rn "
"FROM langchain_pg_embedding e "
"WHERE e.collection_id = (SELECT uuid FROM langchain_pg_collection WHERE name = :collection) "
"AND e.cmetadata->>'docket' = :docket "
"AND ( "
"(string_to_array(COALESCE(e.cmetadata->>'codes', ''), ' ') && CAST(:codes AS text[])) "
"OR (string_to_array(COALESCE(e.cmetadata->>'families', ''), ' ') && ARRAY[:family_key]) "
") "
") one_per_item "
"WHERE item_rn = 1 "
"ORDER BY date DESC NULLS LAST "
"LIMIT :n"
)
_COMMENTS_COLLECTION = "comments"
def docket_counts(
engine: Any, family_key: str, codes: Sequence[str]
) -> list[tuple[str, int, int]]:
"""``(docket, n_items_with_family, n_items_total)`` for every docket
that has any chunks in the ``comments`` collection — one round trip,
``GROUP BY docket``."""
upper = [c.upper() for c in codes]
with engine.begin() as conn:
rows = conn.execute(
_DOCKET_COUNTS_SQL,
{
"codes": upper,
"family_key": family_key.upper(),
"collection": _COMMENTS_COLLECTION,
},
).fetchall()
return [
(docket, int(n_items or 0), int(n_total or 0))
for docket, n_items, n_total in rows
]
def stance_sample(
engine: Any, family_key: str, codes: Sequence[str], docket: str, *, n: int
) -> list[tuple[str, str]]:
"""``(item_key, text)`` for the newest *n* distinct commenters in
*docket* who mentioned the family, first matching chunk, newest
first, text capped to 1,500 chars."""
if n <= 0:
return []
upper = [c.upper() for c in codes]
with engine.begin() as conn:
rows = conn.execute(
_STANCE_SAMPLE_SQL,
{
"codes": upper,
"family_key": family_key.upper(),
"collection": _COMMENTS_COLLECTION,
"docket": docket,
"n": n,
},
).fetchall()
return [(item_key, text or "") for item_key, text in rows]
def _prompt_text(family_key: str, codes: Sequence[str], chunk: str) -> str:
return (
f"Comment on Medicare payment for {family_key} "
f"(codes {' '.join(codes)}):\n{chunk}"
)
def _stance_of(text: str, classify: Callable[[str, Sequence[str]], str | None]) -> str:
stance = classify(text, STANCES)
return stance if stance in STANCES else "unclear"
def classify_stances(
sample: Sequence[tuple[str, str]],
classify: Callable[[str, Sequence[str]], str | None],
) -> Counter:
"""Tally ``STANCES`` for a stance sample — an answer outside the
closed vocabulary counts as ``"unclear"`` (Resolutions)."""
return Counter(_stance_of(text, classify) for _, text in sample)
#: "Comment:" / "Response:" paragraph openers — a docket-comment excerpt
#: reproduced verbatim inside an FR rule's own preamble, always at the
#: very start of the paragraph.
_COMMENT_RE = re.compile(r"^\s*Comment\s*:", re.I)
_RESPONSE_RE = re.compile(r"^\s*Response\s*:", re.I)
def fr_pairs(store: Any, codes: Sequence[str]) -> list[tuple[str, int, int, int]]:
"""``(item_key, rule_year, n_comment_paras, n_response_paras)`` per
rule item: a ``Comment:`` paragraph immediately followed (next
``p_id`` in that item) by a ``Response:`` paragraph, where either
paragraph names one of *codes* — the pre-2017 proxy for public
reaction, since regulations.gov comment text isn't indexed that far
back. Rules with zero qualifying pairs are omitted."""
if not codes:
return []
con = store._con() # noqa: SLF001 — same pattern as pfs.descriptors/pfs.guidance
rows = con.execute(
"SELECT a.item_key, a.p_id, a.text, i.title, i.date_published "
"FROM fr_anchors a JOIN items i ON i.key = a.item_key "
"ORDER BY a.item_key, a.p_id"
).fetchall()
pattern = _code_pattern(codes)
by_item: dict[str, list[tuple[int, str]]] = {}
meta: dict[str, tuple[str, str]] = {}
for item_key, p_id, para_text, title, date_published in rows:
by_item.setdefault(item_key, []).append((p_id, para_text))
meta[item_key] = (title, date_published)
out: list[tuple[str, int, int, int]] = []
for item_key, paras in by_item.items():
n_comment = 0
n_response = 0
for i, (_p_id, para_text) in enumerate(paras):
if not _COMMENT_RE.match(para_text or ""):
continue
if i + 1 >= len(paras):
continue
_next_p_id, next_text = paras[i + 1]
if not _RESPONSE_RE.match(next_text or ""):
continue
combined = f"{para_text} {next_text}"
if not _codes_in(combined, pattern):
continue
n_comment += 1
n_response += 1
if n_comment:
title, date_published = meta[item_key]
out.append(
(item_key, rule_year_of(title, date_published), n_comment, n_response)
)
return sorted(out, key=lambda r: (r[1], r[0]))
def series(
engine: Any,
store: Any,
family: str,
codes: Sequence[str],
*,
classify: Callable[[str, Sequence[str]], str | None] | None = None,
stance_sample_n: int = 0,
) -> list[Any]:
"""Every ``ReactionRow`` for one family (or ``--code`` pseudo-family):
a docket row per docket with any comment chunks, then an fr-pairs row
per rule with qualifying Comment/Response pairs. Stance columns stay
zero (and ``sample_json`` ``"[]"``) unless *classify* is given and
*stance_sample_n* > 0."""
from pfs.codetables import ReactionRow
docket_years = {d.id: d.comment_end_date[:4] for d in store.dockets()}
rows: list[ReactionRow] = []
for docket, n_items, n_total in docket_counts(engine, family, codes):
year_str = docket_years.get(docket, "")
year = int(year_str) if year_str.isdigit() else 0
stance_counts: Counter = Counter()
sample_json = "[]"
if classify is not None and stance_sample_n > 0:
sample = stance_sample(engine, family, codes, docket, n=stance_sample_n)
stances = [
(item_key, _stance_of(_prompt_text(family, codes, chunk), classify))
for item_key, chunk in sample
]
stance_counts = Counter(s for _, s in stances)
sample_json = json.dumps([{"item_key": k, "stance": s} for k, s in stances])
rows.append(
ReactionRow(
family=family,
period=docket,
period_kind="docket",
year=year,
n_items=n_items,
n_total=n_total,
stance_support=stance_counts.get("support", 0),
stance_oppose=stance_counts.get("oppose", 0),
stance_modify=stance_counts.get("modify", 0),
stance_unclear=stance_counts.get("unclear", 0),
sample_json=sample_json,
)
)
for item_key, rule_year, n_comment, n_response in fr_pairs(store, codes):
rows.append(
ReactionRow(
family=family,
period=item_key,
period_kind="fr-pairs",
year=rule_year,
n_items=n_comment,
n_total=n_response,
stance_support=0,
stance_oppose=0,
stance_modify=0,
stance_unclear=0,
sample_json="[]",
)
)
return rows

View File

@@ -1,4 +1,4 @@
"""stack pfs — elements / lineage / families / guidance / review."""
"""stack pfs — elements / lineage / families / guidance / reaction / review."""
from __future__ import annotations
@@ -12,11 +12,13 @@ from pfs.codetables import (
ElementRow,
EventRow,
GuidanceRow,
ReactionRow,
ensure_tables,
read_elements,
read_events,
read_families,
read_guidance,
read_reaction,
write_cpt_edition,
write_elements,
write_events,
@@ -621,6 +623,108 @@ class TestGuidance:
assert res.exit_code != 0
class TestReaction:
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_builds_writes_and_publishes(self, con, monkeypatch):
seen = {}
def fake_series(
engine, store_, family, codes, *, classify=None, stance_sample_n=0
):
seen["engine"] = engine
seen["store"] = store_
seen["family"] = family
seen["codes"] = codes
seen["classify"] = classify
seen["n"] = stance_sample_n
return [self._row(family=family)]
monkeypatch.setattr(pfs_cli, "reaction_series", fake_series)
monkeypatch.setattr(pfs_cli, "_engine", lambda: "ENGINE")
res = runner.invoke(app, ["pfs", "reaction", "--family", "ccm", "--write"])
assert res.exit_code == 0, res.output
assert seen["family"] == "CCM"
assert seen["engine"] == "ENGINE"
assert seen["classify"] is None # no --stance-sample: no LLM at all
assert seen["n"] == 0
assert set(seen["codes"]) == set(pfs_cli.HAND_FAMILIES["CCM"].codes)
got = read_reaction(con, "CCM")
assert [r.period for r in got] == ["CMS-2023-0121"]
assert con.published == [True]
assert "CCM CMS-2023-0121 (2023) 5/20 commenters" in res.output
def test_code_makes_a_single_code_pseudo_family(self, con, monkeypatch):
seen = {}
def fake_series(
engine, store_, family, codes, *, classify=None, stance_sample_n=0
):
seen["family"] = family
seen["codes"] = codes
return [self._row(family=family)]
monkeypatch.setattr(pfs_cli, "reaction_series", fake_series)
monkeypatch.setattr(pfs_cli, "_engine", lambda: "ENGINE")
res = runner.invoke(app, ["pfs", "reaction", "--code", "g2211", "--write"])
assert res.exit_code == 0, res.output
assert seen["family"] == "G2211"
assert seen["codes"] == ("G2211",)
assert [r.family for r in read_reaction(con, "G2211")] == ["G2211"]
def test_stance_sample_builds_the_classifier(self, con, monkeypatch):
seen = {}
monkeypatch.setattr(
pfs_cli,
"reaction_series",
lambda engine, store_, family, codes, *, classify=None, stance_sample_n=0: (
seen.update(classify=classify, n=stance_sample_n) or []
),
)
monkeypatch.setattr(pfs_cli, "_engine", lambda: "ENGINE")
monkeypatch.setattr(pfs_cli, "_classifier", lambda: "CLASSIFIER")
res = runner.invoke(
app, ["pfs", "reaction", "--family", "CCM", "--stance-sample", "5"]
)
assert res.exit_code == 0, res.output
assert seen["classify"] == "CLASSIFIER"
assert seen["n"] == 5
def test_without_write_never_opens_a_batch_connection(self, con, monkeypatch):
monkeypatch.setattr(
pfs_cli,
"reaction_series",
lambda engine, store_, family, codes, *, classify=None, stance_sample_n=0: [
self._row(family=family)
],
)
monkeypatch.setattr(pfs_cli, "_engine", lambda: "ENGINE")
def fail_batch():
raise AssertionError("must not open a RW duckdb_batch connection")
monkeypatch.setattr(pfs_cli, "_batch", fail_batch)
res = runner.invoke(app, ["pfs", "reaction", "--family", "CCM"])
assert res.exit_code == 0, res.output
assert "CCM CMS-2023-0121 (2023) 5/20 commenters" in res.output
assert con.published == [] # nothing persisted, batch never opened
def test_unknown_family_error_lists_hand_families_only(self, con, monkeypatch):
pfs_cli.FAMILIES["ZZZZ-99999"] = pfs_cli.FAMILIES["CCM"]
res = runner.invoke(app, ["pfs", "reaction", "--family", "NOPE", "--write"])
assert res.exit_code != 0
assert "ZZZZ-99999" not in res.output
for key in pfs_cli.HAND_FAMILIES:
assert key in res.output
def test_requires_family_or_code(self, con):
res = runner.invoke(app, ["pfs", "reaction"])
assert res.exit_code != 0
class TestReview:
def test_lists_queue(self, con):
from pfs.codetables import ReviewRow

285
tests/pfs/test_reaction.py Normal file
View File

@@ -0,0 +1,285 @@
"""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()
class TestFrPairs:
def test_counts_only_pairs_naming_a_code(self, store_with_pairs):
s, item_key = store_with_pairs
out = fr_pairs(s, ("99490",))
assert out == [(item_key, 2015, 1, 1)]
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, 1)]
# ── 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,
1,
)
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_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