From 206bf718bdfe2cf712aa12dc6147205f4a9119d3 Mon Sep 17 00:00:00 2001 From: Kisa Date: Sat, 27 Jun 2026 00:33:42 -0400 Subject: [PATCH] Add readiness verdict engine (kills the false green) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grade one coverage line's five documentation items on two separated axes the old code conflated: required_state (from plan_type + payer_rules.json) and input_state (SUPPLIED | ABSENT). 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, never granted by absence of contradiction. - plan_type never guessed: None / unmapped / casing-variant -> "Plan Type Needed", structurally cannot be green; PECOS/PA sit NOT_EVALUATED while universal items (SWO/visit/diagnosis per LCD L33822) still grade. - Required-ness reads from payer_rules.json (single source of truth), removing the latent Python-set-vs-JSON divergence. - Config-surface fail-safe: a known plan missing/partial in config cannot grade green (NOT_EVALUATED), and the green gate checks every item. - Diagnosis accepts a real ICD-10 code as on-file; only explicit negatives gap. Additive only: not wired into the live pipeline. 27 tests green (full suite 73), code-auditor reviewed (H1/H2/M1/M2/M3/M4/L1 fixed). PROVISIONAL, flagged for Pi/Kisa: gap->label mapping (At Risk/Action Needed/ On Track) and the visit-recency scope boundary (timing layer, not readiness). Spec: docs/readiness-model-brief-2026-06-23.md section 4 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ship-status-ledger.md | 2 +- python-backend/core/readiness.py | 286 +++++++++++++++++++++++++++++++ tests/test_readiness.py | 257 +++++++++++++++++++++++++++ 3 files changed, 544 insertions(+), 1 deletion(-) create mode 100644 python-backend/core/readiness.py create mode 100644 tests/test_readiness.py diff --git a/docs/ship-status-ledger.md b/docs/ship-status-ledger.md index 20a563b..af68774 100644 --- a/docs/ship-status-ledger.md +++ b/docs/ship-status-ledger.md @@ -18,7 +18,7 @@ Canonical, git-tracked record of **what shipped, what didn't, and the reason or | 6 | Convex-Signal spike **design** | Pi | **SHIPPED** | docs/convex-signal-spike-design-2026-06-24.md. 40h/3-phase plan; Module map + Repository interface + Convex schema + Auth seam (Convex default, Clerk swappable) + Phase 1 (core readiness loop) = what Claude builds first. | Claude builds Phase 1 from it | | 7 | `signal-convex` repo creation | Claude (GitHub) + Kisa/token (Forgejo) | **PENDING** | Dependency: Pi design + Kisa approval (create once, correctly). Claude can do GitHub via `gh`; Forgejo has no API token here. | after design approved | | 8 | Convex BAA email (plan/cost/signing) | Kisa | **IN PROGRESS** | Kisa handling | Kisa sends | -| 9 | Readiness-model build (rename coverage→readiness, rules-to-config, grouping+verdict, device override, require-plan-type, frontend nesting) | Claude | **IN PROGRESS** | Step 3 grouping half SHIPPED 2026-06-26: `core/dedup.py` (CSVRow→MergedShipment→CoverageLine) + 30 tests green, code-auditor reviewed (no High; M1/M2/L1/L2/L3 fixed). Added additive `plan_type` field to ShipmentRecord (default None; live grading untouched). NOT yet wired into the live pipeline. Remaining: required-vs-input verdict engine, rules-to-addressable-config (IDs), device-keyed override+recompute+rollup, require-plan-type enforcement + remove name-guesser/default, frontend nesting + "plan type needed" state. | Next: build the verdict engine + rules-to-config; WIRE orchestration in `api/main.py` or a new pipeline module, NOT inside `coverage_calculator` (dedup imports from it → circular import). Each step code-auditor-reviewed before ship. | +| 9 | Readiness-model build (rename coverage→readiness, rules-to-config, grouping+verdict, device override, require-plan-type, frontend nesting) | Claude | **IN PROGRESS** | Step 3 SHIPPED 2026-06-26/27 (additive, not yet wired): (a) `core/dedup.py` grouping (CSVRow→MergedShipment→CoverageLine), 30 tests, auditor-reviewed; (b) `core/readiness.py` verdict engine — required-vs-input two-axis grading that KILLS the false green (required+absent never satisfied; clear-to-ship only on positive evidence; plan unknown→"Plan Type Needed"; required-ness read from payer_rules.json not Python sets), 27 tests, auditor-reviewed (H1/H2 config-surface false greens + M2/M3/M4 fixed). Added additive `plan_type` to ShipmentRecord. **OPEN for Kisa/Pi:** provisional gap→label mapping (At Risk/Action Needed/On Track) + H3 scope boundary (visit recency = timing layer, not readiness verdict — confirm). Remaining: rules-to-addressable-config (citation IDs), patient rollup, device-keyed override+recompute+rollup, require-plan-type enforcement + remove name-guesser/default in the LIVE path, frontend nesting + "plan type needed" UX (→ Pi). | Next: hand frontend/label UX to Pi (brief staged); build patient rollup + WIRE orchestration in `api/main.py`/new pipeline module, NOT inside `coverage_calculator` (circular import). Each step auditor-reviewed. | | 10 | NocoDB leads / CRM cleanup | Claude + Kisa | **BLOCKED** | Reason: leads table has no primary key (Signal-base data source `bdmhi02nks1pg6g` is corrupted — a fresh table there also came up PK-less; content base is healthy). SQLite can't add a PK to a populated table (Route 1 failed). Interim: capture still works (inserts via n8n `signal-cgm-lead`); the 2 test rows can't be deleted. | resolved by the data-platform decision (item 11) | | 11 | Data-platform decision (leads + future PHI backend) | Kisa (with Claude/Pi analysis) | **OPEN** | Options analyzed: repaired-NocoDB / Supabase Postgres / Neon / AWS (already set up, BAA+RDS+S3 parked) / Convex ($25/mo incl. BAA). Leads = non-PHI (stay outside PHI boundary). Decision informed by the Convex spike. | run spike → decide | | 12 | Research-versioning Layer-1 (`research-autopush.sh` + Stop-hook entry) | Claude | **NOT STARTED** | Dependency: Pi design exists (`docs/research-versioning-design-2026-06-24.md`). Lower priority than product. | next infra slot | diff --git a/python-backend/core/readiness.py b/python-backend/core/readiness.py new file mode 100644 index 0000000..ca5444a --- /dev/null +++ b/python-backend/core/readiness.py @@ -0,0 +1,286 @@ +""" +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 + +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 + + +@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]) -> 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, + ) + + +def _required_from_config(plan_known: bool, plan_cfg: Optional[dict], + flag_key: str, plan_type: Optional[str]) -> RequiredState: + """Required-ness for a plan-dependent item, read 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). + """ + if not plan_known: + return RequiredState.NOT_EVALUATED + 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 + return RequiredState.REQUIRED if bool(plan_cfg[flag_key]) else RequiredState.NOT_REQUIRED + + +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 = _required_from_config(plan_known, plan_cfg, "pecos_required", plan_type) + pa_required = _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), + _make_item("visit", RequiredState.REQUIRED, + _visit_quality(csv_visit_date, confirmed_visit_date), + visit_date.isoformat() if visit_date else None), + _make_item("diagnosis", RequiredState.REQUIRED, + _diagnosis_quality(csv_diagnosis_on_file), csv_diagnosis_on_file), + _make_item("pecos", pecos_required, _pecos_quality(csv_pecos_verified), csv_pecos_verified), + _make_item("pa", pa_required, _pa_quality(csv_pa_status), csv_pa_status), + ] + + 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 diff --git a/tests/test_readiness.py b/tests/test_readiness.py new file mode 100644 index 0000000..f598939 --- /dev/null +++ b/tests/test_readiness.py @@ -0,0 +1,257 @@ +""" +Tests for the readiness verdict engine — the "kill the false green" logic. + +Source spec: docs/readiness-model-brief-2026-06-23.md section 4 ("The verdict rule"). + +Two axes, never conflated into one string: + required_state: does this plan need this item? (from plan_type + rules) + input_state: did the client supply data? (SUPPLIED | ABSENT) + +Per DocItem: + NOT_REQUIRED + any input -> satisfied (green-eligible) + REQUIRED + SUPPLIED + good -> satisfied + REQUIRED + SUPPLIED + bad -> gap + REQUIRED + ABSENT -> NOT_EVALUATED, blocks green, never satisfied + +A line is "Clear to Ship" ONLY if EVERY required item is satisfied. Green is +earned by positive evidence, never granted by the absence of contradiction. +plan_type unknown -> "Plan Type Needed", structurally cannot be green. +""" + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent / "python-backend")) + +from datetime import date + +from core.readiness import ( + evaluate_readiness, + RequiredState, + InputState, + LineStatus, +) + +VISIT = date(2026, 5, 1) + + +def items_by_type(verdict): + return {i.doc_type: i for i in verdict.doc_items} + + +# --------------------------------------------------------------------------- +# The four-rule verdict table (brief section 4) +# --------------------------------------------------------------------------- + +def test_not_required_item_is_satisfied_even_when_absent(): + """Medicare FFS does not require PA -> PA is satisfied with no input at all.""" + v = evaluate_readiness("medicare", csv_pa_status=None) + pa = items_by_type(v)["pa"] + assert pa.required_state == RequiredState.NOT_REQUIRED + assert pa.satisfied is True + + +def test_required_supplied_good_is_satisfied(): + v = evaluate_readiness("medicare", csv_swo_status="On File") + swo = items_by_type(v)["swo"] + assert swo.required_state == RequiredState.REQUIRED + assert swo.input_state == InputState.SUPPLIED + assert swo.satisfied is True + + +def test_required_supplied_bad_is_gap(): + v = evaluate_readiness("medicare", csv_swo_status="Expired") + swo = items_by_type(v)["swo"] + assert swo.satisfied is False + + +def test_required_absent_blocks_green(): + """THE false-green killer: a required item with no data is never satisfied.""" + v = evaluate_readiness("medicare", csv_swo_status=None) + swo = items_by_type(v)["swo"] + assert swo.required_state == RequiredState.REQUIRED + assert swo.input_state == InputState.ABSENT + assert swo.satisfied is False + + +# --------------------------------------------------------------------------- +# Line status — clear to ship is earned, not defaulted +# --------------------------------------------------------------------------- + +def _all_docs_good(**overrides): + base = dict( + csv_swo_status="On File", + csv_visit_date=VISIT, + csv_pecos_verified="Yes", + csv_pa_status="Not Required", + csv_diagnosis_on_file="Yes", + ) + base.update(overrides) + return base + + +def test_clear_to_ship_when_every_required_item_satisfied(): + v = evaluate_readiness("medicare", **_all_docs_good()) + assert v.line_status == LineStatus.CLEAR_TO_SHIP + + +def test_missing_required_item_is_not_clear_to_ship(): + """Old code greened this (absent column = no gap). New code must not.""" + v = evaluate_readiness("medicare", **_all_docs_good(csv_swo_status=None)) + assert v.line_status != LineStatus.CLEAR_TO_SHIP + assert v.line_status == LineStatus.ACTION_NEEDED + + +def test_supplied_bad_required_item_is_at_risk(): + v = evaluate_readiness("medicare_advantage", **_all_docs_good(csv_pa_status="Denied")) + assert v.line_status == LineStatus.AT_RISK + + +def test_pending_only_is_on_track(): + """No hard gap, no missing data, but an item still in progress -> On Track.""" + v = evaluate_readiness("medicare", **_all_docs_good(csv_swo_status="Pending")) + assert v.line_status == LineStatus.ON_TRACK + + +def test_bad_outranks_absent_in_severity(): + """A denied item (At Risk) outranks a missing item (Action Needed).""" + v = evaluate_readiness( + "medicare_advantage", + **_all_docs_good(csv_pa_status="Denied", csv_diagnosis_on_file=None), + ) + assert v.line_status == LineStatus.AT_RISK + + +# --------------------------------------------------------------------------- +# Plan type unknown — never guess, cannot be green +# --------------------------------------------------------------------------- + +def test_plan_type_none_is_plan_type_needed(): + v = evaluate_readiness(None, **_all_docs_good()) + assert v.line_status == LineStatus.PLAN_TYPE_NEEDED + + +def test_plan_type_unknown_string_is_plan_type_needed(): + """An unmapped plan value is treated as unknown, never defaulted/guessed.""" + v = evaluate_readiness("unknown", **_all_docs_good()) + assert v.line_status == LineStatus.PLAN_TYPE_NEEDED + + +def test_plan_unknown_leaves_pecos_and_pa_not_evaluated(): + v = evaluate_readiness(None, **_all_docs_good()) + items = items_by_type(v) + assert items["pecos"].required_state == RequiredState.NOT_EVALUATED + assert items["pa"].required_state == RequiredState.NOT_EVALUATED + + +def test_plan_unknown_still_grades_universal_items(): + """SWO, visit, diagnosis are universal (LCD L33822) -> still graded when plan unknown.""" + v = evaluate_readiness(None, csv_swo_status="On File") + swo = items_by_type(v)["swo"] + assert swo.required_state == RequiredState.REQUIRED + assert swo.satisfied is True + + +# --------------------------------------------------------------------------- +# Required-ness comes from payer_rules.json, not duplicated Python sets +# --------------------------------------------------------------------------- + +def test_pecos_required_for_medicare(): + v = evaluate_readiness("medicare", csv_pecos_verified=None) + assert items_by_type(v)["pecos"].required_state == RequiredState.REQUIRED + + +def test_pecos_not_required_for_medicaid(): + """Medicaid uses state enrollment, not PECOS (deferred) -> PECOS not required.""" + v = evaluate_readiness("medicaid", csv_pecos_verified=None) + assert items_by_type(v)["pecos"].required_state == RequiredState.NOT_REQUIRED + + +def test_pa_required_for_medicaid(): + v = evaluate_readiness("medicaid", csv_pa_status=None) + assert items_by_type(v)["pa"].required_state == RequiredState.REQUIRED + + +def test_pa_not_required_for_medicare_ffs(): + v = evaluate_readiness("medicare", csv_pa_status=None) + assert items_by_type(v)["pa"].required_state == RequiredState.NOT_REQUIRED + + +def test_all_five_items_present(): + v = evaluate_readiness("medicare", **_all_docs_good()) + assert set(items_by_type(v).keys()) == {"swo", "visit", "pecos", "pa", "diagnosis"} + + +def test_diagnosis_supplied_negative_is_gap(): + v = evaluate_readiness("medicare", **_all_docs_good(csv_diagnosis_on_file="No")) + dx = items_by_type(v)["diagnosis"] + assert dx.input_state == InputState.SUPPLIED + assert dx.satisfied is False + + +# --------------------------------------------------------------------------- +# Hardening (code-auditor findings H1, H2, M1, M2, M3, M4, L1) +# --------------------------------------------------------------------------- + +def test_known_plan_missing_config_does_not_false_green(): + """H1/H2: a known plan absent from payer_rules.json must NOT grade green. + + PECOS/PA cannot be evaluated without their rule config -> NOT_EVALUATED, + which must block 'Clear to Ship'. Fails safe, never open. + """ + v = evaluate_readiness( + "medicaid", rules={"payer_rules": {}}, + **_all_docs_good(csv_pa_status="Denied"), + ) + assert v.line_status != LineStatus.CLEAR_TO_SHIP + assert items_by_type(v)["pa"].required_state == RequiredState.NOT_EVALUATED + assert items_by_type(v)["pecos"].required_state == RequiredState.NOT_EVALUATED + + +def test_diagnosis_icd10_code_counts_as_on_file(): + """M2: a real ICD-10 code in the diagnosis field is positive evidence, not a gap.""" + v = evaluate_readiness("medicare", **_all_docs_good(csv_diagnosis_on_file="E11.9")) + assert items_by_type(v)["diagnosis"].satisfied is True + + +def test_diagnosis_explicit_negative_is_bad(): + v = evaluate_readiness("medicare", **_all_docs_good(csv_diagnosis_on_file="Missing")) + assert items_by_type(v)["diagnosis"].satisfied is False + + +def test_pecos_enrolled_synonym_is_good(): + """M3: standard PECOS portal statuses should read as verified.""" + v = evaluate_readiness("medicare", **_all_docs_good(csv_pecos_verified="Enrolled")) + assert items_by_type(v)["pecos"].satisfied is True + + +def test_swo_active_synonym_is_good(): + v = evaluate_readiness("medicare", **_all_docs_good(csv_swo_status="Active")) + assert items_by_type(v)["swo"].satisfied is True + + +def test_plan_type_case_and_whitespace_normalized(): + """M4: a correctly-mapped plan with stray case/space must still grade.""" + v = evaluate_readiness(" Medicare ", **_all_docs_good()) + assert v.line_status == LineStatus.CLEAR_TO_SHIP + + +def test_staff_confirmed_visit_carries_value_for_citation(): + """L1: a staff-confirmed visit (no CSV date) still records its date for the citation.""" + v = evaluate_readiness( + "medicare", confirmed_visit_date=VISIT, + csv_swo_status="On File", csv_pa_status="Not Required", + csv_diagnosis_on_file="Yes", csv_pecos_verified="Yes", + ) + assert items_by_type(v)["visit"].value == VISIT.isoformat() + + +def test_known_plan_types_match_payer_rules_config(): + """M1 drift guard: the gradeable allow-list must equal the configured plans.""" + import json + rules_path = ( + Path(__file__).parent.parent + / "python-backend" / "config" / "payer_rules.json" + ) + configured = set(json.loads(rules_path.read_text())["payer_rules"].keys()) - {"default"} + from core.readiness import KNOWN_PLAN_TYPES + assert set(KNOWN_PLAN_TYPES) == configured