core/rules.py format-tolerant readers + stable registry (R001-R004 plan-scoped, R100-R102 universal per LCD L33822). payer_rules.json wrapped in the same commit as both engine readers (coupling hazard closed). Every DocItem now cites the rule that decided it; NOT_EVALUATED cites nothing. Independent audit: SHIP (registry-drift test added per its L2 finding). 138 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
307 lines
11 KiB
Python
307 lines
11 KiB
Python
"""
|
|
readiness.py
|
|
Signal — STTIL Solutions
|
|
|
|
The readiness verdict engine: grades ONE coverage line's documentation against
|
|
the five tracked items, separating two axes the old code conflated —
|
|
|
|
required_state: does this plan need this item? (from plan_type + rules)
|
|
input_state: did the client supply data? (SUPPLIED | ABSENT)
|
|
|
|
This kills the "false green": a line is "Clear to Ship" only when EVERY required
|
|
item is satisfied by positive evidence. A required item with no data is
|
|
NOT_EVALUATED and blocks green — green is never granted by absence of a
|
|
contradiction. plan_type is NEVER guessed; unknown -> "Plan Type Needed".
|
|
|
|
Spec: docs/readiness-model-brief-2026-06-23.md section 4.
|
|
|
|
Required-ness is read from payer_rules.json (single source of truth), NOT from
|
|
duplicated Python sets, removing the latent set-vs-JSON divergence the brief
|
|
flagged.
|
|
|
|
PROVISIONAL (flagged for Pi/Kisa): the mapping of gaps to the four UI labels
|
|
(At Risk / Action Needed / On Track) below is a reasonable default. The precise
|
|
label semantics + frontend nesting + "plan type needed" UX are a Pi design task.
|
|
|
|
PHI CONTRACT: operates only on plan_type and the five doc-status inputs. No
|
|
names, SSNs, DOBs, or contact fields.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from datetime import date
|
|
from enum import Enum
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from core.rules import UNIVERSAL_RULE_IDS, rule_id_for, rule_value
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PAYER_RULES_PATH = Path(__file__).parent.parent / "config" / "payer_rules.json"
|
|
|
|
# Plan types Signal can grade. Anything else (including None or "unknown") is
|
|
# treated as "plan type needed" — never defaulted, never guessed.
|
|
KNOWN_PLAN_TYPES = frozenset(
|
|
{"medicare", "medicare_advantage", "medicaid", "commercial"}
|
|
)
|
|
|
|
# Universal claim/coverage requirements per LCD L33822, independent of plan type.
|
|
UNIVERSAL_ITEMS = ("swo", "visit", "diagnosis")
|
|
DOC_TYPES = ("swo", "visit", "pecos", "pa", "diagnosis")
|
|
|
|
|
|
class RequiredState(str, Enum):
|
|
REQUIRED = "REQUIRED"
|
|
NOT_REQUIRED = "NOT_REQUIRED"
|
|
NOT_EVALUATED = "NOT_EVALUATED" # cannot decide (plan type unknown)
|
|
|
|
|
|
class InputState(str, Enum):
|
|
SUPPLIED = "SUPPLIED"
|
|
ABSENT = "ABSENT"
|
|
|
|
|
|
class Quality(str, Enum):
|
|
GOOD = "GOOD" # positive evidence the item is satisfied
|
|
PENDING = "PENDING" # supplied but in progress (not yet good, not bad)
|
|
BAD = "BAD" # supplied and contradicting (denied / expired / no)
|
|
NONE = "NONE" # nothing supplied
|
|
|
|
|
|
class LineStatus(str, Enum):
|
|
CLEAR_TO_SHIP = "Clear to Ship"
|
|
ON_TRACK = "On Track"
|
|
ACTION_NEEDED = "Action Needed"
|
|
AT_RISK = "At Risk"
|
|
PLAN_TYPE_NEEDED = "Plan Type Needed"
|
|
|
|
|
|
@dataclass
|
|
class DocItem:
|
|
doc_type: str
|
|
required_state: RequiredState
|
|
input_state: InputState
|
|
quality: Quality
|
|
value: Optional[str]
|
|
satisfied: bool
|
|
# Names the payer_rules.json rule that decided required_state. None when
|
|
# required_state is NOT_EVALUATED: no rule fired, so nothing is cited.
|
|
rule_id: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
class ReadinessVerdict:
|
|
plan_type: Optional[str]
|
|
doc_items: list[DocItem]
|
|
line_status: LineStatus
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def _load_rules() -> dict:
|
|
with open(PAYER_RULES_PATH, "r") as f:
|
|
return json.load(f)
|
|
|
|
|
|
def _norm(value: Optional[str]) -> str:
|
|
return (value or "").strip().lower()
|
|
|
|
|
|
# --- per-item quality vocabularies -----------------------------------------
|
|
# Provisional synonym sets, fail-safe by design: an unrecognized value resolves
|
|
# to PENDING/BAD (over-flag), never GOOD. These belong next to payer_rules.json
|
|
# once the rules become addressable config; expand from the 25-CSV corpus.
|
|
_SWO_GOOD = {"on file", "on-file", "active", "valid", "received", "signed", "complete"}
|
|
_SWO_BAD = {"expired", "revoked", "cancelled", "canceled", "voided"}
|
|
_PECOS_GOOD = {"yes", "verified", "true", "1", "enrolled", "active", "approved"}
|
|
_PECOS_BAD = {"no", "not verified", "false", "0", "inactive", "denied", "revoked"}
|
|
_PA_GOOD = {"approved", "not required", "authorized", "auth on file", "on file"}
|
|
_PA_BAD = {"denied", "expired", "cancelled", "canceled", "revoked"}
|
|
# Diagnosis carries a real ICD-10 code OR a yes/no flag, so presence = positive
|
|
# evidence and only an EXPLICIT negative is a gap (inverted vs the others).
|
|
_DIAGNOSIS_BAD = {"no", "missing", "none", "false", "0", "not on file", "absent", "n/a"}
|
|
|
|
|
|
def _swo_quality(value: Optional[str]) -> Quality:
|
|
s = _norm(value)
|
|
if not s:
|
|
return Quality.NONE
|
|
if s in _SWO_GOOD:
|
|
return Quality.GOOD
|
|
if s in _SWO_BAD:
|
|
return Quality.BAD
|
|
return Quality.PENDING # "pending" or any other supplied state
|
|
|
|
|
|
def _visit_quality(csv_visit_date: Optional[date],
|
|
confirmed_visit_date: Optional[date]) -> Quality:
|
|
# A confirmed/staff visit date on file is positive evidence. The estimated
|
|
# shipment-minus-30 proxy is NOT client input and does not count here.
|
|
# SCOPE BOUNDARY (brief section 3): this verdict checks the visit is ON FILE.
|
|
# Whether it is still WITHIN the 6-month window is the separate timing layer
|
|
# (CoverageFlag / CoverageLine.timing_flag), combined at the worklist level.
|
|
if csv_visit_date or confirmed_visit_date:
|
|
return Quality.GOOD
|
|
return Quality.NONE
|
|
|
|
|
|
def _diagnosis_quality(value: Optional[str]) -> Quality:
|
|
s = _norm(value)
|
|
if not s:
|
|
return Quality.NONE
|
|
return Quality.BAD if s in _DIAGNOSIS_BAD else Quality.GOOD
|
|
|
|
|
|
def _pecos_quality(value: Optional[str]) -> Quality:
|
|
s = _norm(value)
|
|
if not s:
|
|
return Quality.NONE
|
|
if s in _PECOS_GOOD:
|
|
return Quality.GOOD
|
|
if s in _PECOS_BAD:
|
|
return Quality.BAD
|
|
return Quality.BAD # required Medicare item: unknown fails safe
|
|
|
|
|
|
def _pa_quality(value: Optional[str]) -> Quality:
|
|
s = _norm(value)
|
|
if not s:
|
|
return Quality.NONE
|
|
if s in _PA_GOOD:
|
|
return Quality.GOOD
|
|
if s in _PA_BAD:
|
|
return Quality.BAD
|
|
return Quality.PENDING # "pending", "not started", etc.
|
|
|
|
|
|
def _make_item(doc_type: str, required: RequiredState,
|
|
quality: Quality, value: Optional[str],
|
|
rule_id: Optional[str] = None) -> DocItem:
|
|
input_state = InputState.ABSENT if quality == Quality.NONE else InputState.SUPPLIED
|
|
|
|
if required == RequiredState.NOT_REQUIRED:
|
|
satisfied = True
|
|
elif required == RequiredState.NOT_EVALUATED:
|
|
satisfied = False # cannot confirm -> blocks green
|
|
else: # REQUIRED
|
|
satisfied = quality == Quality.GOOD
|
|
|
|
return DocItem(
|
|
doc_type=doc_type,
|
|
required_state=required,
|
|
input_state=input_state,
|
|
quality=quality,
|
|
value=value,
|
|
satisfied=satisfied,
|
|
rule_id=rule_id,
|
|
)
|
|
|
|
|
|
def _required_from_config(
|
|
plan_known: bool, plan_cfg: Optional[dict],
|
|
flag_key: str, plan_type: Optional[str],
|
|
) -> tuple[RequiredState, Optional[str]]:
|
|
"""(Required-ness, citing rule_id) for a plan-dependent item, from config.
|
|
|
|
A missing config (known plan absent from payer_rules.json, or the flag key
|
|
missing) means we CANNOT decide -> NOT_EVALUATED, which blocks green. We
|
|
never treat a config gap as 'not required' (that was the false-green hole).
|
|
When NOT_EVALUATED, rule_id is None: no rule fired, nothing is cited.
|
|
Accepts both flat and wrapped ({"value":..., "rule_id":...}) config values.
|
|
"""
|
|
if not plan_known:
|
|
return RequiredState.NOT_EVALUATED, None
|
|
if not plan_cfg or flag_key not in plan_cfg:
|
|
logger.warning(
|
|
"plan_type '%s' missing '%s' in payer_rules.json -> cannot grade %s",
|
|
plan_type, flag_key, flag_key,
|
|
)
|
|
return RequiredState.NOT_EVALUATED, None
|
|
required = (
|
|
RequiredState.REQUIRED
|
|
if bool(rule_value(plan_cfg, flag_key))
|
|
else RequiredState.NOT_REQUIRED
|
|
)
|
|
return required, rule_id_for(plan_cfg, flag_key)
|
|
|
|
|
|
def evaluate_readiness(
|
|
plan_type: Optional[str],
|
|
*,
|
|
csv_swo_status: Optional[str] = None,
|
|
csv_visit_date: Optional[date] = None,
|
|
confirmed_visit_date: Optional[date] = None,
|
|
csv_pecos_verified: Optional[str] = None,
|
|
csv_pa_status: Optional[str] = None,
|
|
csv_diagnosis_on_file: Optional[str] = None,
|
|
rules: Optional[dict] = None,
|
|
) -> ReadinessVerdict:
|
|
"""Grade one coverage line's five documentation items and roll up a line status."""
|
|
rules = rules if rules is not None else _load_rules()
|
|
|
|
# Normalize the client-mapped plan type (casing/whitespace only — never guess).
|
|
plan_type = _norm(plan_type) or None
|
|
plan_known = plan_type in KNOWN_PLAN_TYPES
|
|
|
|
# Plan-dependent required-ness from config (single source of truth, never sets).
|
|
plan_cfg = rules.get("payer_rules", {}).get(plan_type) if plan_known else None
|
|
pecos_required, pecos_rule = _required_from_config(plan_known, plan_cfg, "pecos_required", plan_type)
|
|
pa_required, pa_rule = _required_from_config(plan_known, plan_cfg, "pa_required", plan_type)
|
|
|
|
visit_date = csv_visit_date or confirmed_visit_date
|
|
items = [
|
|
_make_item("swo", RequiredState.REQUIRED, _swo_quality(csv_swo_status),
|
|
csv_swo_status, rule_id=UNIVERSAL_RULE_IDS["swo"]),
|
|
_make_item("visit", RequiredState.REQUIRED,
|
|
_visit_quality(csv_visit_date, confirmed_visit_date),
|
|
visit_date.isoformat() if visit_date else None,
|
|
rule_id=UNIVERSAL_RULE_IDS["visit"]),
|
|
_make_item("diagnosis", RequiredState.REQUIRED,
|
|
_diagnosis_quality(csv_diagnosis_on_file), csv_diagnosis_on_file,
|
|
rule_id=UNIVERSAL_RULE_IDS["diagnosis"]),
|
|
_make_item("pecos", pecos_required, _pecos_quality(csv_pecos_verified),
|
|
csv_pecos_verified, rule_id=pecos_rule),
|
|
_make_item("pa", pa_required, _pa_quality(csv_pa_status),
|
|
csv_pa_status, rule_id=pa_rule),
|
|
]
|
|
|
|
return ReadinessVerdict(
|
|
plan_type=plan_type,
|
|
doc_items=items,
|
|
line_status=_line_status(items, plan_known),
|
|
)
|
|
|
|
|
|
def _line_status(items: list[DocItem], plan_known: bool) -> LineStatus:
|
|
"""Roll the five items into one line status.
|
|
|
|
Clear to Ship is reachable ONLY when every required item is satisfied.
|
|
Severity (worst first): Plan Type Needed / At Risk > Action Needed > On Track.
|
|
|
|
PROVISIONAL label mapping (flagged for Pi): At Risk = a supplied-and-bad
|
|
required item (active contradiction); Action Needed = a required item with
|
|
no data to chase; On Track = required items only in progress (pending).
|
|
"""
|
|
if not plan_known:
|
|
return LineStatus.PLAN_TYPE_NEEDED
|
|
|
|
# A known plan whose rules are missing from config cannot be fully graded
|
|
# (PECOS/PA NOT_EVALUATED). Fail safe: surface it, never grade it green.
|
|
if any(i.required_state == RequiredState.NOT_EVALUATED for i in items):
|
|
return LineStatus.PLAN_TYPE_NEEDED
|
|
|
|
# Green is earned only when EVERY item is satisfied (NOT_REQUIRED items are
|
|
# satisfied by definition; required items only by positive evidence).
|
|
if all(i.satisfied for i in items):
|
|
return LineStatus.CLEAR_TO_SHIP
|
|
|
|
required = [i for i in items if i.required_state == RequiredState.REQUIRED]
|
|
if any(i.quality == Quality.BAD for i in required):
|
|
return LineStatus.AT_RISK
|
|
if any(i.input_state == InputState.ABSENT for i in required):
|
|
return LineStatus.ACTION_NEEDED
|
|
return LineStatus.ON_TRACK
|