feat(pfs): lineage for every payable code — one inverted pass over fr_anchors, stack pfs lineage --all-payable (refs #691 #698)
This commit is contained in:
@@ -42,7 +42,7 @@ from pfs.cpt_load import ingest as cpt_ingest
|
|||||||
from pfs.extract import extract_code
|
from pfs.extract import extract_code
|
||||||
from pfs.families import FAMILIES, HAND_FAMILIES, derive_families, refresh_from
|
from pfs.families import FAMILIES, HAND_FAMILIES, derive_families, refresh_from
|
||||||
from pfs.guidance import build as build_guidance
|
from pfs.guidance import build as build_guidance
|
||||||
from pfs.lineage import lineage
|
from pfs.lineage import lineage, lineage_all
|
||||||
from pfs.reaction import series as reaction_series
|
from pfs.reaction import series as reaction_series
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -205,20 +205,83 @@ def _print_events(events: list[Any]) -> None:
|
|||||||
typer.echo(f"{e.year} {e.kind:<18}{arrow:<16}{anchor}{flag}")
|
typer.echo(f"{e.year} {e.kind:<18}{arrow:<16}{anchor}{flag}")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_lineage_all(by_code: dict[str, list[Any]]) -> None:
|
||||||
|
per_source: dict[str, int] = {}
|
||||||
|
with_events = 0
|
||||||
|
unanchored = 0
|
||||||
|
for events in by_code.values():
|
||||||
|
if events:
|
||||||
|
with_events += 1
|
||||||
|
for e in events:
|
||||||
|
per_source[e.source] = per_source.get(e.source, 0) + 1
|
||||||
|
if e.source == "rvu" and not e.anchored:
|
||||||
|
unanchored += 1
|
||||||
|
detail = ", ".join(f"{k}={v}" for k, v in sorted(per_source.items())) or "none"
|
||||||
|
typer.echo(
|
||||||
|
f"lineage: {len(by_code)} codes targeted, {with_events} with >=1 event, "
|
||||||
|
f"events per source: {detail}, unanchored rvu events: {unanchored}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.command("lineage")
|
@app.command("lineage")
|
||||||
def lineage_cmd(
|
def lineage_cmd(
|
||||||
code: str = typer.Option(..., "--code"),
|
code: str = typer.Option("", "--code"),
|
||||||
|
all_payable: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--all-payable",
|
||||||
|
help="Every A/R/T code in the newest RVU year, one inverted pass over fr_anchors.",
|
||||||
|
),
|
||||||
|
limit: int = typer.Option(
|
||||||
|
0,
|
||||||
|
"--limit",
|
||||||
|
help="Only the first N target codes (sorted) — for smoke runs with --all-payable.",
|
||||||
|
),
|
||||||
write: bool = typer.Option(
|
write: bool = typer.Option(
|
||||||
False, "--write", help="Persist to pfs.code_event and republish."
|
False, "--write", help="Persist to pfs.code_event and republish."
|
||||||
),
|
),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Timeline of a code: RVU-file diffs and FR paragraphs, cross-checked."""
|
"""Timeline of a code: RVU-file diffs and FR paragraphs, cross-checked.
|
||||||
|
|
||||||
|
--all-payable computes lineage for every payable code in ONE pass over
|
||||||
|
fr_anchors instead of one query per code.
|
||||||
|
"""
|
||||||
|
if not code and not all_payable:
|
||||||
|
raise typer.BadParameter("pass --code or --all-payable")
|
||||||
store = _store()
|
store = _store()
|
||||||
|
|
||||||
|
if all_payable:
|
||||||
|
# Ruling A13: resolve targets and build every code's lineage
|
||||||
|
# against a plain DuckDB read (I4) — never the write lock a
|
||||||
|
# notebook may be holding (#508-#514). The batch below only ever
|
||||||
|
# calls write_events and publishes.
|
||||||
|
con = _read()
|
||||||
|
try:
|
||||||
|
targets = _codes_for(con, [], [], True)
|
||||||
|
if limit:
|
||||||
|
targets = targets[:limit]
|
||||||
|
by_code = lineage_all(con, store, targets)
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
if write:
|
if write:
|
||||||
with _batch() as con:
|
with _batch() as con:
|
||||||
ensure_tables(con)
|
ensure_tables(con)
|
||||||
|
for c in targets:
|
||||||
|
write_events(con, c, by_code.get(c, []))
|
||||||
|
_publish()
|
||||||
|
_print_lineage_all(by_code)
|
||||||
|
return
|
||||||
|
|
||||||
|
if write:
|
||||||
|
# Same A13 discipline for the single-code path: build the events
|
||||||
|
# against a plain read, then open the batch only to write them.
|
||||||
|
con = _read()
|
||||||
|
try:
|
||||||
events = lineage(con, store, code.upper())
|
events = lineage(con, store, code.upper())
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
_print_events(events)
|
_print_events(events)
|
||||||
|
with _batch() as con:
|
||||||
|
ensure_tables(con)
|
||||||
write_events(con, code.upper(), events)
|
write_events(con, code.upper(), events)
|
||||||
_publish()
|
_publish()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any, Sequence
|
||||||
|
|
||||||
from pfs.codetables import EventRow, cpt_years, is_missing_table_error
|
from pfs.codetables import EventRow, cpt_years, is_missing_table_error
|
||||||
from pfs.descriptors import rule_year_of
|
from pfs.descriptors import rule_year_of
|
||||||
@@ -194,28 +194,39 @@ def _expand_ranges(text: str) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
def fr_events(store: Any, code: str) -> list[EventRow]:
|
def _events_for(
|
||||||
|
code: str, rows: Sequence[Any], *, expanded: bool = False
|
||||||
|
) -> list[EventRow]:
|
||||||
|
"""The event-verb logic for *code* over already-fetched *rows*
|
||||||
|
(``item_key, p_id, page, text, title, date_published`` — a sqlite
|
||||||
|
``Row`` mapping or a plain dict). ``expanded=True`` means ``text`` is
|
||||||
|
already range-expanded and FR-citation-stripped (``fr_events_bucketed``
|
||||||
|
computes that once per row, shared across every code it hits) — pass
|
||||||
|
``expanded=False`` (the default) for raw ``fr_anchors.text``.
|
||||||
|
|
||||||
|
Presence and "other codes nearby" both go through ``find_codes`` (word
|
||||||
|
boundaries, FR-citation-safe) rather than a bare substring check, so
|
||||||
|
this agrees exactly with the ``find_codes(text) & targets`` hit test
|
||||||
|
``fr_events_bucketed`` uses to decide which bucket a row lands in."""
|
||||||
code = code.upper()
|
code = code.upper()
|
||||||
con = store._con()
|
|
||||||
rows = con.execute(
|
|
||||||
"SELECT a.item_key, a.p_id, a.page, a.text, i.title, i.date_published "
|
|
||||||
"FROM fr_anchors a JOIN items i ON i.key = a.item_key WHERE a.text LIKE ? OR a.text LIKE ?",
|
|
||||||
(f"%{code}%", f"%{code[:2]}___-{code[:2]}___%"),
|
|
||||||
).fetchall()
|
|
||||||
patterns = _patterns(code)
|
patterns = _patterns(code)
|
||||||
seen: set[tuple[int, str, str, int]] = set()
|
seen: set[tuple[int, str, str, int]] = set()
|
||||||
out: list[EventRow] = []
|
out: list[EventRow] = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
text = _expand_ranges(r["text"])
|
if expanded:
|
||||||
# An FR page citation's number ("91 FR 99490") is not a mention of
|
text = r["text"]
|
||||||
# the code — strip it before both the presence check and every
|
else:
|
||||||
# event pattern below (M1: a "crosswalk ... 91 FR 99490" sentence
|
# An FR page citation's number ("91 FR 99490") is not a
|
||||||
# used to read as a crosswalk event for 99490).
|
# mention of the code — strip it before both the presence
|
||||||
text = FR_CITE_RE.sub(" ", text)
|
# check and every event pattern below (M1: a "crosswalk ...
|
||||||
if code not in text.upper():
|
# 91 FR 99490" sentence used to read as a crosswalk event
|
||||||
|
# for 99490).
|
||||||
|
text = FR_CITE_RE.sub(" ", _expand_ranges(r["text"]))
|
||||||
|
codes_here = find_codes(text)
|
||||||
|
if code not in codes_here:
|
||||||
continue
|
continue
|
||||||
year = rule_year_of(r["title"], r["date_published"])
|
year = rule_year_of(r["title"], r["date_published"])
|
||||||
others = tuple(c for c in find_codes(text) if c != code)
|
others = tuple(c for c in codes_here if c != code)
|
||||||
for kind, pat, direction in patterns:
|
for kind, pat, direction in patterns:
|
||||||
m = pat.search(text)
|
m = pat.search(text)
|
||||||
if not m:
|
if not m:
|
||||||
@@ -265,6 +276,53 @@ def fr_events(store: Any, code: str) -> list[EventRow]:
|
|||||||
return sorted(out, key=lambda e: (e.year, FR_KINDS.index(e.kind), e.p_id))
|
return sorted(out, key=lambda e: (e.year, FR_KINDS.index(e.kind), e.p_id))
|
||||||
|
|
||||||
|
|
||||||
|
def fr_events(store: Any, code: str) -> list[EventRow]:
|
||||||
|
code = code.upper()
|
||||||
|
con = store._con()
|
||||||
|
rows = con.execute(
|
||||||
|
"SELECT a.item_key, a.p_id, a.page, a.text, i.title, i.date_published "
|
||||||
|
"FROM fr_anchors a JOIN items i ON i.key = a.item_key WHERE a.text LIKE ? OR a.text LIKE ?",
|
||||||
|
(f"%{code}%", f"%{code[:2]}___-{code[:2]}___%"),
|
||||||
|
).fetchall()
|
||||||
|
return _events_for(code, rows)
|
||||||
|
|
||||||
|
|
||||||
|
def fr_events_bucketed(store: Any, codes: Sequence[str]) -> dict[str, list[EventRow]]:
|
||||||
|
"""``_events_for`` for every one of *codes* in ONE streaming pass over
|
||||||
|
``fr_anchors`` instead of one SQL query (two LIKE prefilters) per
|
||||||
|
code — the whole point for an 8.7k-code universe over 193k
|
||||||
|
paragraphs. Each row's text is range-expanded and FR-citation-stripped
|
||||||
|
exactly once, then dropped into every target code's bucket that
|
||||||
|
``find_codes`` finds it naming.
|
||||||
|
|
||||||
|
``fr_events_bucketed(store, [c])[c] == fr_events(store, c)`` for any
|
||||||
|
single code ``c``: ``fr_events``'s LIKE prefilter is a superset of its
|
||||||
|
own presence check, and both presence checks now agree (``_events_for``
|
||||||
|
docstring), so the two paths select exactly the same rows for *c*."""
|
||||||
|
targets = {c.upper() for c in codes}
|
||||||
|
buckets: dict[str, list[dict[str, Any]]] = {c: [] for c in targets}
|
||||||
|
con = store._con()
|
||||||
|
for r in con.execute(
|
||||||
|
"SELECT a.item_key, a.p_id, a.page, 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"
|
||||||
|
):
|
||||||
|
text = FR_CITE_RE.sub(" ", _expand_ranges(r["text"]))
|
||||||
|
hits = targets & set(find_codes(text))
|
||||||
|
if not hits:
|
||||||
|
continue
|
||||||
|
row = {
|
||||||
|
"item_key": r["item_key"],
|
||||||
|
"p_id": r["p_id"],
|
||||||
|
"page": r["page"],
|
||||||
|
"text": text,
|
||||||
|
"title": r["title"],
|
||||||
|
"date_published": r["date_published"],
|
||||||
|
}
|
||||||
|
for c in hits:
|
||||||
|
buckets[c].append(row)
|
||||||
|
return {c: _events_for(c, rows, expanded=True) for c, rows in buckets.items()}
|
||||||
|
|
||||||
|
|
||||||
def _cpt_ev(code: str, year: int, kind: str, item_key: str, note: str) -> EventRow:
|
def _cpt_ev(code: str, year: int, kind: str, item_key: str, note: str) -> EventRow:
|
||||||
return EventRow(code, year, kind, "", "", item_key, 0, 0, "cpt", True, note)
|
return EventRow(code, year, kind, "", "", item_key, 0, 0, "cpt", True, note)
|
||||||
|
|
||||||
@@ -318,14 +376,51 @@ def cpt_events(con: Any, code: str) -> list[EventRow]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def lineage(con: Any, store: Any, code: str) -> list[EventRow]:
|
def _merge(
|
||||||
fr = fr_events(store, code)
|
code: str,
|
||||||
cpt = cpt_events(con, code)
|
rvu: list[EventRow],
|
||||||
|
fr: list[EventRow],
|
||||||
|
cpt: list[EventRow],
|
||||||
|
) -> list[EventRow]:
|
||||||
|
"""Merge one code's rvu/fr/cpt events, marking each rvu event
|
||||||
|
``anchored`` when an fr or cpt event for the same code lies within
|
||||||
|
±1 rule year — shared by ``lineage`` (per-code) and ``lineage_all``
|
||||||
|
(bucketed) so both anchor identically."""
|
||||||
anchor_years = {e.year for e in fr} | {e.year for e in cpt}
|
anchor_years = {e.year for e in fr} | {e.year for e in cpt}
|
||||||
rvu = [
|
anchored_rvu = [
|
||||||
dataclasses.replace(
|
dataclasses.replace(
|
||||||
ev, anchored=any(abs(ev.year - y) <= 1 for y in anchor_years)
|
ev, anchored=any(abs(ev.year - y) <= 1 for y in anchor_years)
|
||||||
)
|
)
|
||||||
for ev in rvu_events(con, code)
|
for ev in rvu
|
||||||
]
|
]
|
||||||
return sorted([*rvu, *fr, *cpt], key=lambda e: (e.year, e.source != "fr", e.kind))
|
return sorted(
|
||||||
|
[*anchored_rvu, *fr, *cpt], key=lambda e: (e.year, e.source != "fr", e.kind)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def lineage(con: Any, store: Any, code: str) -> list[EventRow]:
|
||||||
|
fr = fr_events(store, code)
|
||||||
|
cpt = cpt_events(con, code)
|
||||||
|
rvu = rvu_events(con, code)
|
||||||
|
return _merge(code, rvu, fr, cpt)
|
||||||
|
|
||||||
|
|
||||||
|
def lineage_all(
|
||||||
|
con: Any, store: Any, codes: Sequence[str]
|
||||||
|
) -> dict[str, list[EventRow]]:
|
||||||
|
"""``lineage`` for every one of *codes*, computing fr events with ONE
|
||||||
|
pass over ``fr_anchors`` (``fr_events_bucketed``) instead of one per
|
||||||
|
code; rvu/cpt events stay per-code (cheap replica reads) and are
|
||||||
|
merged with the same ±1-year anchoring ``lineage`` uses, via
|
||||||
|
``_merge``."""
|
||||||
|
codes_u = [c.upper() for c in codes]
|
||||||
|
fr_by_code = fr_events_bucketed(store, codes_u)
|
||||||
|
return {
|
||||||
|
code: _merge(
|
||||||
|
code,
|
||||||
|
rvu_events(con, code),
|
||||||
|
fr_by_code.get(code, []),
|
||||||
|
cpt_events(con, code),
|
||||||
|
)
|
||||||
|
for code in codes_u
|
||||||
|
}
|
||||||
|
|||||||
@@ -270,6 +270,77 @@ class TestLineage:
|
|||||||
assert read_calls == [True]
|
assert read_calls == [True]
|
||||||
assert con.published == []
|
assert con.published == []
|
||||||
|
|
||||||
|
def test_requires_code_or_all_payable(self, con):
|
||||||
|
res = runner.invoke(app, ["pfs", "lineage"])
|
||||||
|
assert res.exit_code != 0
|
||||||
|
|
||||||
|
def test_all_payable_limit_write_calls_writer_once_per_code_after_build(
|
||||||
|
self, con, monkeypatch
|
||||||
|
):
|
||||||
|
# Extra A/R/T, newest-year codes so --limit has something to
|
||||||
|
# trim: sorted, the newest-year A/R/T targets are
|
||||||
|
# 99490 < A0001 < B0002 ('9' sorts before letters); 99999 is
|
||||||
|
# status B (excluded) and G2058 is a stale year (excluded).
|
||||||
|
con.execute(
|
||||||
|
"INSERT INTO pfs.rvu VALUES (?,?,?,?,?,?)",
|
||||||
|
["A0001", None, "extra a", "A", 1.0, 2026],
|
||||||
|
)
|
||||||
|
con.execute(
|
||||||
|
"INSERT INTO pfs.rvu VALUES (?,?,?,?,?,?)",
|
||||||
|
["B0002", None, "extra b", "R", 1.0, 2026],
|
||||||
|
)
|
||||||
|
order: list[tuple] = []
|
||||||
|
|
||||||
|
def fake_lineage_all(con_, store_, codes):
|
||||||
|
order.append(("lineage_all", tuple(codes)))
|
||||||
|
return {c: [] for c in codes}
|
||||||
|
|
||||||
|
monkeypatch.setattr(pfs_cli, "lineage_all", fake_lineage_all)
|
||||||
|
|
||||||
|
written: list[str] = []
|
||||||
|
|
||||||
|
def fake_write_events(con_, code, rows):
|
||||||
|
order.append(("write", code))
|
||||||
|
written.append(code)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(pfs_cli, "write_events", fake_write_events)
|
||||||
|
|
||||||
|
real_batch = pfs_cli._batch
|
||||||
|
|
||||||
|
def recording_batch():
|
||||||
|
order.append(("batch",))
|
||||||
|
return real_batch()
|
||||||
|
|
||||||
|
monkeypatch.setattr(pfs_cli, "_batch", recording_batch)
|
||||||
|
|
||||||
|
res = runner.invoke(
|
||||||
|
app, ["pfs", "lineage", "--all-payable", "--limit", "2", "--write"]
|
||||||
|
)
|
||||||
|
assert res.exit_code == 0, res.output
|
||||||
|
assert order[0] == ("lineage_all", ("99490", "A0001"))
|
||||||
|
assert order[1] == ("batch",)
|
||||||
|
assert order[2:] == [("write", "99490"), ("write", "A0001")]
|
||||||
|
assert written == ["99490", "A0001"]
|
||||||
|
assert con.published == [True]
|
||||||
|
|
||||||
|
def test_all_payable_without_write_never_opens_the_batch(self, con, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
pfs_cli, "lineage_all", lambda c, s, codes: {c: [] for c in codes}
|
||||||
|
)
|
||||||
|
|
||||||
|
def fail_batch():
|
||||||
|
raise AssertionError(
|
||||||
|
"lineage --all-payable without --write must not open a RW "
|
||||||
|
"duckdb_batch connection"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(pfs_cli, "_batch", fail_batch)
|
||||||
|
res = runner.invoke(app, ["pfs", "lineage", "--all-payable"])
|
||||||
|
assert res.exit_code == 0, res.output
|
||||||
|
assert con.published == []
|
||||||
|
assert "lineage: 1 codes targeted" in res.output
|
||||||
|
|
||||||
|
|
||||||
class TestFamilies:
|
class TestFamilies:
|
||||||
def test_derives_from_tables_and_writes(self, con):
|
def test_derives_from_tables_and_writes(self, con):
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ from pfs.lineage import (
|
|||||||
_expand_ranges,
|
_expand_ranges,
|
||||||
cpt_events,
|
cpt_events,
|
||||||
fr_events,
|
fr_events,
|
||||||
|
fr_events_bucketed,
|
||||||
lineage,
|
lineage,
|
||||||
|
lineage_all,
|
||||||
rvu_events,
|
rvu_events,
|
||||||
rvu_year_span,
|
rvu_year_span,
|
||||||
)
|
)
|
||||||
@@ -363,3 +365,73 @@ class TestCrossCheck:
|
|||||||
def test_sorted_by_year_then_source(self, con, store):
|
def test_sorted_by_year_then_source(self, con, store):
|
||||||
ev = lineage(con, store, "99439")
|
ev = lineage(con, store, "99439")
|
||||||
assert [e.year for e in ev] == sorted(e.year for e in ev)
|
assert [e.year for e in ev] == sorted(e.year for e in ev)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFrEventsBucketed:
|
||||||
|
# The four target codes from `store`'s three rule items: a plain
|
||||||
|
# mention (99490), a replaced_by/crosswalk pair (G2058 -> 99439) and
|
||||||
|
# a range-form deletion (99441, from "99441-99443").
|
||||||
|
_CODES = ("99490", "G2058", "99439", "99441")
|
||||||
|
|
||||||
|
def test_matches_fr_events_per_code(self, store):
|
||||||
|
bucketed = fr_events_bucketed(store, self._CODES)
|
||||||
|
for c in self._CODES:
|
||||||
|
assert bucketed[c] == fr_events(store, c), c
|
||||||
|
|
||||||
|
def test_range_form_middle_member_matches(self, store):
|
||||||
|
# 90868 is a middle member of a "90867-90869" range and appears
|
||||||
|
# nowhere else in the paragraph literally — both paths must find
|
||||||
|
# it via range expansion, not a literal LIKE hit.
|
||||||
|
bucketed = fr_events_bucketed(store, ["90868"])
|
||||||
|
assert bucketed["90868"] == fr_events(store, "90868")
|
||||||
|
assert any(e.kind == "deleted" for e in bucketed["90868"])
|
||||||
|
|
||||||
|
def test_fr_citation_page_number_is_not_a_mention(self, store):
|
||||||
|
# M1, bucketed path: a page citation ("91 FR 99490") must not
|
||||||
|
# make 99490 register a hit at all, not just skip the crosswalk
|
||||||
|
# pattern — matching fr_events's own guard.
|
||||||
|
store.con.execute(
|
||||||
|
"INSERT INTO fr_anchors VALUES (?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
"DE2VH9PD",
|
||||||
|
1500,
|
||||||
|
67999,
|
||||||
|
1500,
|
||||||
|
"We note the crosswalk methodology discussed at 91 FR 99490 for related codes.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
bucketed = fr_events_bucketed(store, ["99490"])
|
||||||
|
assert bucketed["99490"] == fr_events(store, "99490")
|
||||||
|
assert not any(e.kind == "crosswalk" for e in bucketed["99490"])
|
||||||
|
|
||||||
|
def test_unmentioned_code_gets_empty_bucket(self, store):
|
||||||
|
assert fr_events_bucketed(store, ["00000"]) == {"00000": []}
|
||||||
|
|
||||||
|
|
||||||
|
class TestLineageAll:
|
||||||
|
def test_matches_lineage_per_code(self, con, store):
|
||||||
|
codes = ["99487", "G2058", "99439"]
|
||||||
|
by_code = lineage_all(con, store, codes)
|
||||||
|
for c in codes:
|
||||||
|
assert by_code[c] == lineage(con, store, c), c
|
||||||
|
|
||||||
|
def test_rvu_event_anchored_by_cpt_changed_within_one_year(self, con, store):
|
||||||
|
_cpt_section(con, 2024, "GQGTPGYV")
|
||||||
|
_cpt_reference(con, 2024, "GQGTPGYV", "99487", [2016])
|
||||||
|
by_code = lineage_all(con, store, ["99487"])
|
||||||
|
by = {(e.kind, e.year): e for e in by_code["99487"]}
|
||||||
|
assert by[("status_change", 2017)].anchored is True
|
||||||
|
assert ("cpt_changed", 2016) in by
|
||||||
|
|
||||||
|
def test_rvu_events_anchored_by_nearby_fr_event(self, con, store):
|
||||||
|
by_code = lineage_all(con, store, ["G2058", "99487"])
|
||||||
|
by_kind = {e.kind: e for e in by_code["G2058"]}
|
||||||
|
assert by_kind["appeared"].year == 2020 and by_kind["appeared"].anchored is True
|
||||||
|
assert (
|
||||||
|
by_kind["disappeared"].year == 2021
|
||||||
|
and by_kind["disappeared"].anchored is True
|
||||||
|
)
|
||||||
|
assert all(not e.anchored for e in by_code["99487"] if e.source == "rvu")
|
||||||
|
|
||||||
|
def test_unknown_code_yields_empty_list(self, con, store):
|
||||||
|
assert lineage_all(con, store, ["99999"]) == {"99999": []}
|
||||||
|
|||||||
Reference in New Issue
Block a user