design: readiness model Phase 2 build brief for Claude (rules-config, rollup, override, plan-type enforcement) [Pi]
This commit is contained in:
parent
7f5ed90bac
commit
5b1748ed25
1 changed files with 283 additions and 0 deletions
283
docs/readiness-model-design-phase2-2026-06-26.md
Normal file
283
docs/readiness-model-design-phase2-2026-06-26.md
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
# Readiness Model Design Brief — Phase 2 for Claude
|
||||||
|
|
||||||
|
**Date:** 2026-06-26
|
||||||
|
**Status:** Design output from Pi. Claude builds from this.
|
||||||
|
**Author:** Pi (designed) → Claude (builds)
|
||||||
|
**Prerequisite context:** `docs/readiness-model-brief-2026-06-23.md` (Phase 1 spec — dedup + verdict engine ALREADY SHIPPED by Claude)
|
||||||
|
**E2E verification:** `tests/signal_e2e_test.py` (33 black-box tests, Pi runs, all green)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. State of play
|
||||||
|
|
||||||
|
**What shipped (Phase 1, done and tested):**
|
||||||
|
|
||||||
|
- `core/dedup.py` — CSV row → MergedShipment → CoverageLine grouping. Locked dedup key (patient_id, device_type, DOS). Plan type conflict resolution, quantity merge, component split handling, transfer flag carry-forward. 42 tests (30 Claude unit + 12 Pi E2E).
|
||||||
|
- `core/readiness.py` — Verdict engine. Two-axis grading (required_state + input_state). False green killed. All 5 line statuses reachable. Plan type never guessed. Config-based required-ness (payer_rules.json). 46 tests (27 Claude unit + 19 Pi E2E).
|
||||||
|
- Code is **additive only** — not wired into the live pipeline. The old `coverage_calculator.py` still runs.
|
||||||
|
|
||||||
|
**What remains to build (Phase 2):**
|
||||||
|
|
||||||
|
1. Rules-to-addressable-config (citation IDs on every rule)
|
||||||
|
2. Synonym vocabularies in config (move from Python sets to payer_rules.json)
|
||||||
|
3. Patient rollup (worst-gradeable-line aggregation)
|
||||||
|
4. Device-keyed override + recompute + rollup
|
||||||
|
5. Require-plan-type enforcement + remove name-guesser/default in LIVE path
|
||||||
|
6. Frontend nesting + "plan type needed" UX (Pi design, Claude builds)
|
||||||
|
|
||||||
|
**This brief covers items 1-5 (backend). Item 6 is a separate Pi design brief for the frontend.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. E2E findings that require design decisions
|
||||||
|
|
||||||
|
### 2A. PECOS "Pending" synonym gap
|
||||||
|
|
||||||
|
The test `test_readiness_on_track` proved that **SWO and PA accept "Pending" as a legitimate supplied state** (their quality functions include a PENDING fallback for unrecognized values). PECOS does not — it returns BAD for any unknown value.
|
||||||
|
|
||||||
|
**Current behavior:**
|
||||||
|
|
||||||
|
- `csv_swo_status="Pending"` → quality PENDING (not GOOD, not BAD)
|
||||||
|
- `csv_pa_status="Pending"` → quality PENDING (not GOOD, not BAD)
|
||||||
|
- `csv_pecos_verified="Pending"` → quality BAD (fail-safe forces to BAD for required Medicare items)
|
||||||
|
|
||||||
|
**Design decision:** Add "pending" to `_PECOS_GOOD`? Or keep it BAD because PECOS verification is binary (enrolled/not enrolled, no in-progress state)?
|
||||||
|
|
||||||
|
**My recommendation:** Keep PECOS "Pending" as BAD. PECOS enrollment is a binary check done by the supplier at setup. "Pending" means they have not checked yet, or the check is incomplete. Both cases are a real gap. SWO and PA have legitimate in-progress states (document pending review, authorization in process). PECOS does not. **Document this in supplier training: PECOS values must be Yes/Enrolled/Active or No/Inactive — "Pending" will flag as At Risk.**
|
||||||
|
|
||||||
|
### 2B. _load_rules mutability hazard
|
||||||
|
|
||||||
|
`_load_rules()` uses `@lru_cache(maxsize=1)` and returns the mutable dict directly. Any caller that modifies the returned dict poisons the shared cache for all subsequent callers (the `test_readiness_known_plan_missing_config` bug).
|
||||||
|
|
||||||
|
**Design decision:** Should `_load_rules` return a frozen/deep-copied dict, or leave it mutable (faster, no copy overhead)?
|
||||||
|
|
||||||
|
**My recommendation:** Leave it mutable. The dict is only ever read in production (no production code mutates it). The test bug is a testing mistake, not a production risk. Add a docstring warning: "Returns the live cached dict. Do not mutate outside testing."
|
||||||
|
|
||||||
|
### 2C. Commercial PA requirement confirmed
|
||||||
|
|
||||||
|
`payer_rules.json` sets `pa_required: true` for commercial plans. This was tested and verified — missing PA for a commercial patient correctly produces ACTION_NEEDED. No code change needed. Document for supplier training: commercial plan patients need PA just like MA and Medicaid.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Phase 2 build sequence (dependency-ordered)
|
||||||
|
|
||||||
|
### Item 1: Rules-to-addressable-config (citation IDs)
|
||||||
|
|
||||||
|
**Why:** Every verdict must cite the rule that fired (section 5 of Phase 1 brief). Prerequisite for cascade entries and audit trail.
|
||||||
|
|
||||||
|
**What to build:**
|
||||||
|
|
||||||
|
Add a `rule_id` to every rule value in `payer_rules.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"payer_rules": {
|
||||||
|
"medicare": {
|
||||||
|
"visit_renewal_days": { "value": 180, "rule_id": "R001" },
|
||||||
|
"refill_window_days": { "value": 30, "rule_id": "R002" },
|
||||||
|
"pa_required": { "value": false, "rule_id": "R003" },
|
||||||
|
"pecos_required": { "value": true, "rule_id": "R004" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `_load_rules()` to understand the new format (backward compatible with flat values for now — the old flat format is also accepted so the CURRENT payer_rules.json still works).
|
||||||
|
|
||||||
|
Update `_required_from_config()` to return the `rule_id` alongside `RequiredState`.
|
||||||
|
|
||||||
|
Update `evaluate_readiness()` to include `rule_id` in each `DocItem` (add a `rule_id: Optional[str]` field).
|
||||||
|
|
||||||
|
**Rule ID registry (stable — never change an ID after shipping):**
|
||||||
|
|
||||||
|
| Rule ID | Name | Description |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| R001 | visit_renewal_days | Visit renewal window in days (standard: 180) |
|
||||||
|
| R002 | refill_window_days | Refill window in days before coverage end (standard: 30) |
|
||||||
|
| R003 | pa_required | Prior authorization required for this plan type |
|
||||||
|
| R004 | pecos_required | PECOS enrollment verification required for this plan type |
|
||||||
|
| R100 | swo_required | SWO is a universal claim requirement per LCD L33822 |
|
||||||
|
| R101 | visit_required | Qualifying visit is a universal requirement per LCD L33822 |
|
||||||
|
| R102 | diagnosis_required | Diagnosis (ICD-10) is a universal requirement per LCD L33822 |
|
||||||
|
|
||||||
|
R100-R102 are always REQUIRED regardless of plan type. They live in the code as constants (no plan-type dependency), but get rule IDs for citation consistency.
|
||||||
|
|
||||||
|
**Tests:** `test_evaluate_readiness_returns_rule_ids` — verify each DocItem has the correct rule_id.
|
||||||
|
|
||||||
|
### Item 2: Move synonym vocabularies to config
|
||||||
|
|
||||||
|
**Why:** The synonym sets live in Python hardcoded sets:
|
||||||
|
|
||||||
|
```python
|
||||||
|
_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_BAD = {"no", "missing", "none", "false", "0", "not on file", "absent", "n/a"}
|
||||||
|
```
|
||||||
|
|
||||||
|
These need stable rule IDs and belong in `payer_rules.json` alongside the other rules. This is the "extract rules to addressable config" step from the Phase 1 brief (section 5).
|
||||||
|
|
||||||
|
**Add to `payer_rules.json`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
"vocabularies": {
|
||||||
|
"swo_good": { "values": ["on file", "on-file", "active", "valid", "received", "signed", "complete"], "rule_id": "V001" },
|
||||||
|
"swo_bad": { "values": ["expired", "revoked", "cancelled", "canceled", "voided"], "rule_id": "V002" },
|
||||||
|
"pecos_good": { "values": ["yes", "verified", "true", "1", "enrolled", "active", "approved"], "rule_id": "V003" },
|
||||||
|
"pecos_bad": { "values": ["no", "not verified", "false", "0", "inactive", "denied", "revoked"], "rule_id": "V004" },
|
||||||
|
"pa_good": { "values": ["approved", "not required", "authorized", "auth on file", "on file"], "rule_id": "V005" },
|
||||||
|
"pa_bad": { "values": ["denied", "expired", "cancelled", "canceled", "revoked"], "rule_id": "V006" },
|
||||||
|
"diagnosis_bad": { "values": ["no", "missing", "none", "false", "0", "not on file", "absent", "n/a"], "rule_id": "V007" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The vocabularies go into their own top-level key (not nested under payer_rules) because they are NOT payer-specific — they apply to all plan types. The READER functions (e.g., `_swo_quality`, `_pecos_quality`) read from the vocab section.
|
||||||
|
|
||||||
|
**Update quality functions:** Each quality function accepts an optional `rules` dict (matching the existing pattern on `evaluate_readiness`). If `rules` has a `vocabularies` section, read synonym sets from there. If not, fall back to the Python hardcoded sets (backward compatible).
|
||||||
|
|
||||||
|
Document the PECOS design decision: "Pending" is intentionally not in pecos_good — it produces BAD, which is correct for required Medicare items where PECOS verification should be binary.
|
||||||
|
|
||||||
|
**Tests:** Existing synonym tests still pass (fallback). Add `test_vocabularies_in_config_override_python_sets` — config-sourced synonyms take precedence.
|
||||||
|
|
||||||
|
### Item 3: Patient rollup
|
||||||
|
|
||||||
|
**Why:** The worklist shows one row per patient, not per coverage line (section 3 of Phase 1 brief). Patient-level status rolls up from coverage lines.
|
||||||
|
|
||||||
|
**What to build:**
|
||||||
|
|
||||||
|
Create a `PatientRollup` dataclass:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class PatientRollup:
|
||||||
|
patient_id: str
|
||||||
|
lines: list[CoverageLine] # all coverage lines for this patient
|
||||||
|
rollup_status: LineStatus # worst gradeable line determines this
|
||||||
|
cgm_line_count: int # gradeable CGM lines
|
||||||
|
total_line_count: int # all lines (including non-gradeable)
|
||||||
|
plan_type_needed: bool # any line in PLAN_TYPE_NEEDED
|
||||||
|
```
|
||||||
|
|
||||||
|
Rollup rules (from section 4 of Phase 1 brief):
|
||||||
|
|
||||||
|
- Light = worst gradeable line. Severity: Plan Type Needed > At Risk > Action Needed > On Track > Clear to Ship.
|
||||||
|
- Any gradeable line in "Plan Type Needed" caps the patient no greener than Action Needed.
|
||||||
|
- Non-CGM (gradeable=False) lines are excluded from the light and all tab counts.
|
||||||
|
- Tabs count patients, not rows.
|
||||||
|
|
||||||
|
**Design note on Plan Type Needed capping:** A patient with one line in Clear to Ship and another in Plan Type Needed should show as Action Needed (not Plan Type Needed — that would be misleading because the patient IS being served, just not all devices are gradeable). This prevents "Plan Type Needed" from overwhelming the worklist when the supplier has one missing plan type mapping for a secondary device.
|
||||||
|
|
||||||
|
**Tests:** `test_patient_rollup_single_line`, `test_patient_rollup_mixed_statuses`, `test_patient_rollup_plan_type_needed_capping`, `test_patient_rollup_non_cgm_excluded`.
|
||||||
|
|
||||||
|
### Item 4: Device-keyed override + recompute + rollup
|
||||||
|
|
||||||
|
**Why:** Staff overrides (e.g., confirming a visit date, marking SWO as on-file) need to apply to the correct device, recompute that line, and re-roll-up the patient (section 6 of Phase 1 brief).
|
||||||
|
|
||||||
|
**What to build:**
|
||||||
|
|
||||||
|
Create `Override` dataclass:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OverrideKey:
|
||||||
|
org_id: str
|
||||||
|
patient_hash: str
|
||||||
|
device_type: str
|
||||||
|
doc_type: str # "visit" | "swo" | "pa" | "pecos" | "diagnosis"
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Override:
|
||||||
|
key: OverrideKey
|
||||||
|
status: str # e.g., "Confirmed", "On File", "Approved"
|
||||||
|
status_date: date
|
||||||
|
expiry_date: Optional[date]
|
||||||
|
confirmed_by: str
|
||||||
|
confirmed_at: datetime
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a function `apply_overrides(coverage_lines: dict[CoverageLineKey, CoverageLine], overrides: list[Override])`:
|
||||||
|
|
||||||
|
- For each line, check if any override exists whose OverrideKey matches.
|
||||||
|
- If VISIT doc_type override exists, apply to ALL lines for that patient (visit is patient-scoped).
|
||||||
|
- If any other doc_type override exists, apply only to lines with matching device_type (line-scoped).
|
||||||
|
- After applying overrides, re-grade the affected line(s).
|
||||||
|
- After re-grading affected lines, re-roll-up the patient.
|
||||||
|
|
||||||
|
**Scope boundary from Phase 1 brief (section 10):** Only two override types in pilot scope: visit (patient-scoped) + SWO/PA (line-scoped). No general override framework. No override for PECOS or diagnosis this phase.
|
||||||
|
|
||||||
|
**Tests:** `test_override_visit_applies_to_all_patient_lines`, `test_override_swo_applies_only_to_one_device`, `test_override_triggers_recompute`, `test_recompute_triggers_rerollup`.
|
||||||
|
|
||||||
|
### Item 5: Require-plan-type enforcement + remove name-guesser in LIVE path
|
||||||
|
|
||||||
|
**Why:** The old `_normalize_payer` in `coverage_calculator.py` keyword-matches payer names and can route an unknown plan to a default grade path (section 8 + blocker 1 of Phase 1 brief). This violates the "never guess" decision. The new dedup+readiness path already enforces this, but the LIVE pipeline still uses the OLD path.
|
||||||
|
|
||||||
|
**What to build:**
|
||||||
|
|
||||||
|
1. In `coverage_calculator.py`, add a path that routes through the new dedup+readiness pipeline when `plan_type` is available on `ShipmentRecord`. Gate on: if `record.plan_type is not None`, use the new path. If `record.plan_type is None`, the old path remains (for backward compatibility with existing CSV uploads that lack the mapped field).
|
||||||
|
|
||||||
|
2. Deprecate `_normalize_payer` with a loud warning log: "WARNING: plan_type not provided — falling back to payer-name guess. This path will be removed. Supply plan_type in CSV import."
|
||||||
|
|
||||||
|
3. Add a `ReadinessVerdict` field to `CoverageResult` so the frontend can read readiness status alongside coverage flags. This is a DATA MODEL change: `CoverageResult` gains an optional `readiness: ReadinessVerdict` field that is None when using the old path.
|
||||||
|
|
||||||
|
**Tests:** `test_live_path_routes_to_new_engine_when_plan_type_present`, `test_live_path_falls_back_when_plan_type_missing`, `test_deprecation_warning_logged`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Migration strategy (zero-downtime)
|
||||||
|
|
||||||
|
The old path and new path must coexist during migration:
|
||||||
|
|
||||||
|
1. **Phase 2a (this build):** New dedup+readiness runs alongside old pipeline. Both paths execute. `CoverageResult` carries `readiness` as an optional field. Frontend ignores it until wired (item 6, separate Pi brief). Old path still serves the worklist. Supplier training for plan_type mapping begins.
|
||||||
|
|
||||||
|
2. **Phase 2b (after frontend is ready):** Frontend switches to new readiness data. Old path still runs but no one looks at it.
|
||||||
|
|
||||||
|
3. **Phase 2c (after validation):** Old pipeline removed. `plan_type` becomes REQUIRED on CSV import. `_normalize_payer` deleted.
|
||||||
|
|
||||||
|
**No cutoff date — Kisa decides when to cut over.** Code must support both paths simultaneously.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API surface for frontend
|
||||||
|
|
||||||
|
After Phase 2, the `/worklist` endpoint returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"patient_id": "P001",
|
||||||
|
"rollup_status": "ACTION_NEEDED",
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"device_type": "dexcom_g7",
|
||||||
|
"plan_type": "medicare",
|
||||||
|
"gradeable": true,
|
||||||
|
"line_status": "Clear to Ship",
|
||||||
|
"doc_items": [
|
||||||
|
{"doc_type": "swo", "status": "On File", "required": true, "satisfied": true, "rule_id": "R100"},
|
||||||
|
{"doc_type": "visit", "status": "2026-05-15", "required": true, "satisfied": true, "rule_id": "R101"},
|
||||||
|
{"doc_type": "pecos", "status": "Yes", "required": true, "satisfied": true, "rule_id": "R004"},
|
||||||
|
{"doc_type": "pa", "status": "Not Required", "required": false, "satisfied": true, "rule_id": "R003"},
|
||||||
|
{"doc_type": "diagnosis", "status": "E11.9", "required": true, "satisfied": true, "rule_id": "R102"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend renders per the separate UX design brief (item 6, Pi-side).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Open design questions for Kisa
|
||||||
|
|
||||||
|
1. **PECOS "Pending" treatment.** My recommendation: keep as BAD (binary field, no in-progress state). Confirm?
|
||||||
|
2. **Plan Type Needed capping rule.** My recommendation: cap at Action Needed, not Plan Type Needed, when only one of multiple lines is unknown. Confirm?
|
||||||
|
3. **Migration timeline.** No fixed date — Kisa decides when to cut over. Start supplier training on plan_type mapping when the frontend is ready.
|
||||||
|
4. **Phase 2c (delete old pipeline).** Only after the new path has served real customers for at least 2 weeks without regression. Confirm?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*End of Readiness Model Design Brief — Phase 2.*
|
||||||
|
|
||||||
|
*Claude: Build items 1-5 in order. Each step is auditor-reviewed before the next begins.
|
||||||
|
Pi: Will design the frontend UX (item 6) as a separate brief when you reach Phase 2b.*
|
||||||
Loading…
Reference in a new issue