fix(bib,llm): rule kind from the rule's own text — proposed rules were labelled 'final' because CMS titles since CY2018 carry no kind word (refs #691 #692)
Some checks failed
CI / lint (push) Successful in 29s
CI / notebooks-smoke (push) Successful in 1m27s
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / llm (push) Has been skipped
Deploy / mc (push) Has been skipped
CI / test (push) Has been cancelled
Deploy / report (push) Has been cancelled
Infra CI / zotero (push) Has been cancelled
Infra CI / docs (push) Has been cancelled
Infra CI / api (push) Has been cancelled
Infra CI / llm (push) Has been cancelled
Infra CI / mc (push) Has been cancelled
Infra CI / notebooks (push) Has been cancelled

bib.frlink.rule_kind decides proposed/final/correction from the FR paragraphs
("this proposed rule"/"we propose" vs "this final rule"/"we are finalizing";
a Correction title wins outright); the title is only the fallback and an
undecidable rule is labelled 'rule', never 'final'. All 80 grabbed PFS rules
classify correctly (30 proposed, 28 final, 22 corrections) — ten post-2017
proposed rules (incl. the CY2027 NPRM 5ITGVDJV and XFGGRBDH) were 'final' before.
Lineage labels, the collapse tie-break and the golden audio-only label use it.
This commit is contained in:
kert
2026-09-10 15:24:26 -04:00
parent bb7d0e025e
commit 6ed7ae5e4d
5 changed files with 269 additions and 16 deletions

View File

@@ -434,6 +434,83 @@ def resolve(
# ── Transmutations + rendering ────────────────────────────────────── # ── Transmutations + rendering ──────────────────────────────────────
RULE_KINDS = ("proposed", "final", "correction", "rule")
# A rule's own body text says what it is: a proposed rule refers to itself
# as "this proposed rule" and says "we propose"/"we are proposing"; a final
# rule says "this final rule" and "we are finalizing"/"we finalized" (and
# "we proposed", past tense, which these patterns deliberately exclude).
_PROPOSED_SIGNALS = (
"%this proposed rule%",
"%we propose %",
"%we are proposing%",
)
_FINAL_SIGNALS = (
"%this final rule%",
"%we are finalizing%",
"%we finalized%",
)
def _signal_count(con: Any, item_key: str, patterns: tuple[str, ...]) -> int:
where = " OR ".join("text LIKE ?" for _ in patterns)
row = con.execute(
f"SELECT count(*) FROM fr_anchors WHERE item_key = ? AND ({where})", # noqa: S608
(item_key, *patterns),
).fetchone()
return int(row[0]) if row else 0
def kind_from_title(title: str) -> str:
"""``proposed`` / ``final`` / ``correction`` from a rule's title, else
``rule``. Titles are the FALLBACK only — since CY2018 the CMS titles
("Medicare and Medicaid Programs; CY 2027 Payment Policies Under the
Physician Fee Schedule …") carry no proposed/final word at all."""
t = (title or "").lower()
if "correction" in t or "correcting amendment" in t:
return "correction"
if "proposed" in t:
return "proposed"
if "final" in t:
return "final"
return "rule"
def rule_kind(store: Store, item_key: str, *, title: str | None = None) -> str:
"""What kind of FR document *item_key* is: ``proposed``, ``final``,
``correction`` or ``rule`` (undecidable — never claim ``final``
without evidence).
A title that says "Correction" wins outright — a correction notice
quotes the rule it corrects, so its text is not evidence of its
own kind. Otherwise the rule's OWN paragraphs decide
(``fr_anchors``): the proposed/final self-reference counts are
decisive when one is at least three times the other. Measured on
the 80 grabbed PFS rules (2001–2026) every proposed and final rule
is decisive this way, including the ten post-2017 proposed rules
whose titles never say "Proposed". Short notices with no
self-references fall back to the title (``kind_from_title``).
*title* may be passed by a caller that already holds the item
(saves the ``store.get`` round trip); ``None`` fetches it, and an
unknown item is ``rule``."""
if title is None:
try:
title = store.get(item_key).title
except Exception: # noqa: BLE001 — unknown item: undecidable, not "final"
title = ""
if kind_from_title(title) == "correction":
return "correction"
con = store._con() # noqa: SLF001
proposed = _signal_count(con, item_key, _PROPOSED_SIGNALS)
final = _signal_count(con, item_key, _FINAL_SIGNALS)
if proposed and proposed >= 3 * final:
return "proposed"
if final and final >= 3 * proposed:
return "final"
return kind_from_title(title)
def page_of(store: Store, item_key: str, p_id: int) -> int: def page_of(store: Store, item_key: str, p_id: int) -> int:
"""The printed FR page a paragraph anchor sits on.""" """The printed FR page a paragraph anchor sits on."""
row = _anchor_row(store._con(), item_key, p_id) # noqa: SLF001 row = _anchor_row(store._con(), item_key, p_id) # noqa: SLF001

View File

@@ -299,15 +299,30 @@ class LineageEvidence:
def rule_label( def rule_label(
title: str, date_published: str, p_id: int, *, vol: str = "", page: int = 0 title: str,
date_published: str,
p_id: int,
*,
vol: str = "",
page: int = 0,
kind: str = "",
) -> str: ) -> str:
"""``"CY2021 PFS final 85 FR 84639 ¶1578"`` when *vol*/*page* are """``"CY2021 PFS final 85 FR 84639 ¶1578"`` when *vol*/*page* are
known (Ruling B13 — ``I2``), else the shorter known (Ruling B13 — ``I2``), else the shorter
``"CY2021 PFS final ¶1578"`` / ``"CY2027 PFS proposed ¶394"``. ``"CY2021 PFS final ¶1578"`` / ``"CY2027 PFS proposed ¶394"``.
``proposed`` when *title* contains "Proposed" (case-insensitive),
else ``final``; the year from ``rule_year_of``.""" *kind* is ``proposed`` / ``final`` / ``correction`` / ``rule`` as
decided by ``bib.frlink.rule_kind`` from the rule's own text (the
chat passes it via ``_rule_kind``). Without it the title decides
(``bib.frlink.kind_from_title``) — and a title that says neither
labels the row ``rule``, never ``final``: since CY2018 the CMS
titles carry no proposed/final word, and a proposed rule presented
as final is a false statement of policy. The year comes from
``rule_year_of``."""
from bib.frlink import kind_from_title # bib is an optional extra
year = rule_year_of(title, date_published) year = rule_year_of(title, date_published)
kind = "proposed" if "proposed" in (title or "").lower() else "final" kind = kind or kind_from_title(title)
if vol and page: if vol and page:
return f"CY{year} PFS {kind} {vol} FR {page}{p_id}" return f"CY{year} PFS {kind} {vol} FR {page}{p_id}"
return f"CY{year} PFS {kind}{p_id}" return f"CY{year} PFS {kind}{p_id}"
@@ -377,6 +392,10 @@ class _ReplicaCache:
_ITEM_CACHE = _ReplicaCache() _ITEM_CACHE = _ReplicaCache()
_URL_CACHE = _ReplicaCache() _URL_CACHE = _ReplicaCache()
_PARAGRAPH_CACHE = _ReplicaCache() _PARAGRAPH_CACHE = _ReplicaCache()
#: item_key -> "proposed"/"final"/"correction"/"rule" (bib.frlink.rule_kind),
#: keyed on the bib mtime like the item cache: the answer reads the rule's
#: paragraphs, so a re-grab can change it.
_KIND_CACHE = _ReplicaCache()
#: code -> on-demand EventRow list — keyed on the DuckDB REPLICA's mtime #: code -> on-demand EventRow list — keyed on the DuckDB REPLICA's mtime
#: (passed explicitly by callers): on-demand lineage is derived from #: (passed explicitly by callers): on-demand lineage is derived from
#: ``pfs.rvu``/``pfs.code_event``, which live on the replica, not the #: ``pfs.rvu``/``pfs.code_event``, which live on the replica, not the
@@ -431,7 +450,36 @@ def _source_label(store: Any, item_key: str, p_id: int, mtime: int) -> str:
if para is not None: if para is not None:
vol = str(para.get("fr_volume") or "") vol = str(para.get("fr_volume") or "")
page = para.get("page") or 0 page = para.get("page") or 0
return rule_label(item.title, item.date_published, p_id, vol=vol, page=page) return rule_label(
item.title,
item.date_published,
p_id,
vol=vol,
page=page,
kind=_rule_kind(store, item_key, mtime),
)
def _rule_kind(store: Any, item_key: str, mtime: int) -> str:
"""``bib.frlink.rule_kind`` for *item_key*, cached per item on the
bib mtime; ``rule`` (undecided) when the store can't answer — a
label must never say ``final`` on a failure path."""
if store is None or not item_key:
return "rule"
cached = _KIND_CACHE.get(item_key, mtime)
if cached is not _MISSING:
return cached
try:
from bib.frlink import rule_kind
item = _cached_item(store, item_key, mtime) # one Store.get per item
title = (item.title or "") if item is not None else ""
kind = rule_kind(store, item_key, title=title)
except Exception as e: # noqa: BLE001
log.warning("lineage rule kind unresolved (%s): %s", item_key, e)
kind = "rule"
_KIND_CACHE.put(item_key, mtime, kind)
return kind
def _fr_url(store: Any, item_key: str, p_id: int, mtime: int) -> str: def _fr_url(store: Any, item_key: str, p_id: int, mtime: int) -> str:
@@ -499,16 +547,12 @@ def _resolve_prompt_urls(
def _is_proposed(store: Any, item_key: str, mtime: int) -> bool: def _is_proposed(store: Any, item_key: str, mtime: int) -> bool:
"""True when *item_key*'s title contains "proposed" — ``False`` (not """True when ``bib.frlink.rule_kind`` decides *item_key* is a
"unknown") for an empty *item_key* or a *store*-less caller, which proposed rule (from the rule's own text, title as fallback) —
``False`` for an empty *item_key* or a *store*-less caller, which
keeps every ``_pref_key`` caller that doesn't have a store (existing keeps every ``_pref_key`` caller that doesn't have a store (existing
tests, mostly) exactly as deterministic as before Ruling B12.""" tests, mostly) exactly as deterministic as before Ruling B12."""
if store is None or not item_key: return _rule_kind(store, item_key, mtime) == "proposed"
return False
item = _cached_item(store, item_key, mtime)
if item is None:
return False
return "proposed" in (item.title or "").lower()
def _pref_key( def _pref_key(

View File

@@ -360,3 +360,90 @@ class TestQuoteTieBreak:
with pytest.raises(ValueError, match="matches 2 paragraphs"): with pytest.raises(ValueError, match="matches 2 paragraphs"):
frlink.resolve("shared quote text appears here", store=s) frlink.resolve("shared quote text appears here", store=s)
s.close() s.close()
# ── rule_kind ───────────────────────────────────────────────────────
def _store_with_text(title: str, paragraphs: list[str]) -> tuple[Store, str]:
s, key = _store_with_rule()
s._con().execute("UPDATE items SET title = ? WHERE key = ?", (title, key)) # noqa: SLF001
s._con().executemany( # noqa: SLF001
"INSERT INTO fr_anchors (item_key, p_id, page, ordinal, text) VALUES (?,?,?,?,?)",
[(key, i + 1, 100, i + 1, text) for i, text in enumerate(paragraphs)],
)
s._con().commit() # noqa: SLF001
return s, key
class TestRuleKind:
def test_proposed_from_text_when_the_title_says_nothing(self) -> None:
s, key = _store_with_text(
"Medicare and Medicaid Programs; CY 2027 Payment Policies Under the PFS",
[
"In this proposed rule we propose to update the conversion factor.",
"We are proposing to revise the telehealth list.",
"Commenters on the CY 2026 final rule asked for more time.",
],
)
assert frlink.rule_kind(s, key) == "proposed"
def test_final_from_text_when_the_title_says_nothing(self) -> None:
s, key = _store_with_text(
"Medicare and Medicaid Programs; CY 2025 Payment Policies Under the PFS",
[
"After considering comments, we are finalizing the policy as proposed.",
"In this final rule we finalized the APCM codes.",
"We proposed the change in the CY 2025 PFS proposed rule.",
],
)
assert frlink.rule_kind(s, key) == "final"
def test_past_tense_we_proposed_is_not_a_proposed_signal(self) -> None:
s, key = _store_with_text(
"Medicare Program; CY 2021 Payment Policies Under the PFS",
["We proposed this in July. We are finalizing it now in this final rule."],
)
assert frlink.rule_kind(s, key) == "final"
def test_silent_text_falls_back_to_the_title(self) -> None:
s, key = _store_with_text(
"Medicare Program; CY 2016 PFS Correction", ["Corrections to page 1."]
)
assert frlink.rule_kind(s, key) == "correction"
s2, key2 = _store_with_text("Medicare Program; CY 2005 PFS Proposed Rule", [])
assert frlink.rule_kind(s2, key2) == "proposed"
def test_undecidable_is_rule_never_final(self) -> None:
s, key = _store_with_text("Medicare Program; Revisions to Payment Policies", [])
assert frlink.rule_kind(s, key) == "rule"
def test_unknown_item_is_rule(self) -> None:
s, _ = _store_with_rule()
assert frlink.rule_kind(s, "NOPE0000") == "rule"
def test_kind_from_title(self) -> None:
assert frlink.kind_from_title("CY 2025 PFS Proposed Rule") == "proposed"
assert frlink.kind_from_title("CY 2025 PFS Final Rule") == "final"
assert (
frlink.kind_from_title("CY 2025 PFS Final Rule; Correction") == "correction"
)
assert frlink.kind_from_title("CY 2025 Payment Policies") == "rule"
def test_correction_title_wins_over_the_quoted_text(self) -> None:
"""A correction notice quotes the rule it corrects ("this final
rule …"), so its text is not evidence of its own kind."""
s, key = _store_with_text(
"Medicare Program; CY 2025 PFS Final Rule; Correction",
[
"On page 97864 of this final rule, we are finalizing … is corrected to read"
],
)
assert frlink.rule_kind(s, key) == "correction"
def test_title_kwarg_skips_the_item_lookup(self) -> None:
s, key = _store_with_text("x", ["In this proposed rule we propose a change."])
assert (
frlink.rule_kind(s, "NOPE0000", title="CY 2005 PFS Final Rule") == "final"
)
assert frlink.rule_kind(s, key, title="") == "proposed"

View File

@@ -66,7 +66,9 @@
- item_key: XFGGRBDH - item_key: XFGGRBDH
p_id: 489 p_id: 489
expect_labels: expect_labels:
- 'CY2025 PFS final .*¶489' # XFGGRBDH is the CY2025 *proposed* rule (91 FR, 2024-07-31); its title
# carries no "Proposed", so the kind must come from the rule's text.
- 'CY2025 PFS proposed .*¶489'
expect_events: expect_events:
- code: "99441" - code: "99441"
kind: deleted|disappeared|cpt_deleted kind: deleted|disappeared|cpt_deleted

View File

@@ -148,6 +148,17 @@ class _FakeStore:
"A temporary crosswalk between G2058 and new CPT code " "A temporary crosswalk between G2058 and new CPT code "
"99439 (with a descriptor identical to G2058).", "99439 (with a descriptor identical to G2058).",
), ),
# The proposed rule decides its kind from its own text —
# bib.frlink.rule_kind counts "this proposed rule"/"we
# propose" against "this final rule"/"we are finalizing".
(
"ZPROP2027",
394,
32388,
1,
"In this proposed rule we propose to keep the three-step "
"telehealth review.",
),
], ],
) )
@@ -214,14 +225,44 @@ def _gd(
class TestRuleLabel: class TestRuleLabel:
def test_final(self): def test_title_without_a_kind_word_is_rule_not_final(self):
"""Since CY2018 the CMS titles say neither "Proposed" nor
"Final"; without a decided kind the label must not claim
``final`` (a proposed rule presented as final is a false
statement of policy)."""
assert ( assert (
rule_label( rule_label(
"Medicare Program; CY 2021 Payment Policies Under the PFS", "Medicare Program; CY 2021 Payment Policies Under the PFS",
"2020-12-28", "2020-12-28",
1578, 1578,
) )
== "CY2021 PFS final ¶1578" == "CY2021 PFS rule ¶1578"
)
def test_final_from_title(self):
assert (
rule_label("Medicare Program; CY 2015 PFS Final Rule", "2014-11-13", 1251)
== "CY2015 PFS final ¶1251"
)
def test_kind_from_the_rule_text_overrides_the_title(self):
"""The chat passes ``kind`` decided by ``bib.frlink.rule_kind``
from the rule's own paragraphs — a title that says nothing
still labels a proposed rule ``proposed``."""
assert (
rule_label(
"Medicare and Medicaid Programs; CY 2027 Payment Policies",
"2026-07-16",
394,
kind="proposed",
)
== "CY2027 PFS proposed ¶394"
)
def test_correction_from_title(self):
assert (
rule_label("Medicare Program; CY 2016 PFS Correction", "2016-03-08", 12)
== "CY2016 PFS correction ¶12"
) )
def test_proposed(self): def test_proposed(self):
@@ -242,6 +283,7 @@ class TestRuleLabel:
1578, 1578,
vol="85", vol="85",
page=84639, page=84639,
kind="final",
) )
== "CY2021 PFS final 85 FR 84639 ¶1578" == "CY2021 PFS final 85 FR 84639 ¶1578"
) )
@@ -253,6 +295,7 @@ class TestRuleLabel:
"2020-12-28", "2020-12-28",
1578, 1578,
vol="85", vol="85",
kind="final",
) )
== "CY2021 PFS final ¶1578" == "CY2021 PFS final ¶1578"
) )