Signal/docs/dedup-design-2026-06-24.md
Kisa 6f3ce16960 verify+fix: Pi's dedup + Convex-spike designs build-ready [CC]
- dedup: separate client-mapped plan_type (not payer) so CoverageLine never guesses plan type; fix merge_quantities to sum across distinct component configs (blanket-max dropped qty in mixed dup+split groups)
- convex-spike: shipments orderNumber/hcpcs -> arrays (post-dedup multi-value); de-stale dedup note to LOCKED
- ledger: Design Verification section added

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:53:10 -04:00

12 KiB

Dedup Design — Patient + Device + DOS Grouping

Date: 2026-06-24 Design by: Pi (decision locked by Kisa) Builds by: Claude (Phase: patient/line/shipment grouping + required-vs-input verdict) Source: docs/readiness-model-brief-2026-06-23.md, Kisa dedup decision 2026-06-24 Verification fixes (Claude, 2026-06-24): (1) added a separate client-mapped plan_type field, distinct from display payer, so CoverageLine keys on the mapped plan type and never the payer name (locked 2026-06-23); (2) fixed merge_quantities to sum across distinct component configs instead of taking a blanket max.


1. The Locked Dedup Key

(patient_id, device_type, date_of_service)

Three fields, no more. Order_number is displayed as an attribute, never used in dedup logic.

Scales to all DMEPOS categories (CGM, CPAP, wheelchair, oxygen, wig, bed, ostomy). The rationale is documented in the audit at docs/review-audit-delegation-research-versioning-2026-06-24.md and the Kisa decision in context/current-state.md.


2. Where Dedup Fits in the Pipeline

The current pipeline grades ONE record at a time (the known bug). The fixed pipeline:

CSV rows
  │
  ├── Step 1: Normalize (map field names, parse dates, validate required fields)
  │
  ├── Step 2: DEDUP (this design)
  │     Group rows by (patient_id, device_type, DOS)
  │     → MergedShipment (one per group)
  │
  ├── Step 3: Assign to CoverageLines
  │     Each MergedShipment → coverageLine (patient + device + plan)
  │     Latest DOS per line → isCurrent = true
  │
  ├── Step 4: Grade current line
  │     gradeLine() → verdict per CoverageLine
  │
  └── Step 5: Roll up
        rollupPatient() → patient-level status

3. MergedShipment Shape (After Dedup)

@dataclass
class MergedShipment:
    """One deduped shipment after grouping CSV rows by (patient_id, device_type, DOS)."""
    patient_id: str
    device_type: str
    shipment_date: date
    total_quantity: int                     # summed from all rows in the group
    components: list[str]                   # combined set, deduped
    order_numbers: list[str]                # collected, displayed only
    hcpcs_codes: list[str]                  # collected, displayed only
    payer: str                              # raw payer NAME from the LATEST row — DISPLAY ONLY
    plan_type: str                          # REQUIRED client-mapped plan type (medicare|medicare_advantage|medicaid|commercial|unknown); NEVER derived from payer name; used for CoverageLine keying + grading
    # CSV doc fields — merged from all rows, latest non-null wins
    csv_visit_date: date | None
    csv_swo_status: str | None
    csv_pecos_verified: str | None
    csv_pa_status: str | None
    csv_diagnosis_on_file: str | None
    csv_transfer_from: str | None

4. Tiebreaker Rules (Within a Dedup Group)

When multiple CSV rows have the same (patient_id, device_type, DOS):

Field Rule Why
quantity Sum across rows Each row represents components; total is the sum of all components delivered
components Combine into set, dedup duplicates A row with [sensor] + a row with [sensor, transmitter] → [sensor, transmitter]
order_numbers Collect into list, dedup duplicates Displayed as "ORD-100, ORD-101" — collapsed to "ORD-100, ORD-101 (2 orders)" if same number
hcpcs_codes Collect into list, dedup duplicates Displayed as "A4230, A4231"
payer Latest non-null wins Last row processed in the group. Rows are processed in ingestion order.
csv_* doc fields Latest non-null wins If one row has csv_swo_status="On File" and another has null, the non-null wins. If both have different values, latest wins.
shipment_date Must be identical after normalization to date If two rows have different dates after normalization, they belong to different groups. If same calendar date but different timestamps, normalize to date first.

Quantity edge cases

Scenario Input Result Rationale
Normal split Row A qty=3, Row B qty=1 total_qty = 4 Sensor + transmitter = 4 components
Duplicate Row A qty=3, Row B qty=3 (same components) total_qty = 6 ⚠️ This IS the risk of summing: a data-entry duplicate doubles the quantity. Mitigation: flag rows where components are identical across rows in the same group. If all fields except order_number match exactly, it is a likely duplicate → take max, not sum.
Zero row Row A qty=3, Row B qty=0 total_qty = 3 Zero-quantity rows are informational (e.g., a cancelation) and do not add to quantity
Wildly different Row A qty=3, Row B qty=100 (same component) total_qty = 100 Almost certainly an error. Same component config, so take max (100, not sum); flag for review and log a warning.

Sum, but guard against identical-component duplicates. The rule:

def merge_quantities(rows_in_group: list[CSVRow]) -> int:
    """Sum quantities across DISTINCT component configs within a dedup group,
    while collapsing exact-duplicate rows.

    Group rows by their component set. Within one config, identical rows are
    likely data-entry duplicates, so take the MAX for that config (not the sum).
    Then SUM across distinct configs (a real component split, e.g. sensor + transmitter).

    Fixes the mixed case: two duplicate [sensor] rows + one [transmitter] row
    -> max(sensor)=3 + max(transmitter)=1 = 4, not a blanket global max of 3.
    """
    from collections import defaultdict

    by_config: dict[frozenset, list[int]] = defaultdict(list)
    for row in rows_in_group:
        config = frozenset(row.components.split(","))
        by_config[config].append(row.quantity)

    # Per distinct config: max (defend against duplicate rows). Across configs: sum.
    return sum(max(qtys) for qtys in by_config.values())

5. How This Feeds CoverageLines

Each MergedShipment is assigned to a CoverageLine keyed by (patient_id, device_type, plan_type_id).

def assign_to_coverage_lines(merged_shipments: list[MergedShipment]) -> dict[CoverageLineKey, CoverageLine]:
    """Group shipments into CoverageLines by (patient_id, device_type, plan_type)."""
    lines: dict[CoverageLineKey, list[MergedShipment]] = {}

    for shipment in merged_shipments:
        # plan_type is the client-mapped REQUIRED field, never the payer name (locked 2026-06-23)
        key = CoverageLineKey(
            patient_id=shipment.patient_id,
            device_type=shipment.device_type,
            plan_type_id=shipment.plan_type,  # mapped plan type; payer name is display-only
        )
        lines.setdefault(key, []).append(shipment)

    result = {}
    for key, shipments in lines.items():
        # Sort by DOS, latest = current
        shipments.sort(key=lambda s: s.shipment_date, reverse=True)
        current = shipments[0]
        gradeable = is_cgm(key.device_type)  # CGM only for Phase 1; expand later

        result[key] = CoverageLine(
            patient_id=key.patient_id,
            device_type=key.device_type,
            plan_type=key.plan_type_id,
            gradeable=gradeable,
            current_shipment=current,
            prior_shipments=shipments[1:],
            line_status=None,   # computed by gradeLine()
            timing_flag=None,   # computed by gradeLine()
        )

    return result

6. Frontend Display After Dedup

Before dedup (current behavior — the bug):

Patient  | Device   | DOS       | Order  | Status
ABC-123  | Dex G7   | 2026-06-01| ORD-100| Resupply Ready
ABC-123  | Dex G7   | 2026-06-01| ORD-101| Resupply Ready

After dedup:

Patient  | Device   | DOS       | Orders             | Qty | Components          | Status
ABC-123  | Dex G7   | 2026-06-01| ORD-100, ORD-101   | 4   | sensor, transmitter  | Resupply Ready

Order numbers column: Shows a comma-separated list. If >3 order numbers, show "ORD-100, ORD-101 +2 more" with expand/collapse.

Components column: Shows the combined set. If only one component, show "sensor". If multiple, show "sensor, transmitter".

Quantity column: Shows the sum. Hover shows detail: "3 sensors + 1 transmitter" is derived from components + quantity (not explicitly stored, but inferred from the component splits).


7. Edge Cases Log

Edge Case Handled? How
Only one row for the key No merging needed; MergedShipment = the single row
Missing quantity Treat quantity as 1 (a row without a qty is still a shipment)
Missing components Default to the device_type's default component from payer_rules.json
Missing order_number Order_numbers list is empty; display shows "—"
No order_numbers at all in CSV Entire column hidden
DOS different due to timezone Normalize to YYYY-MM-DD UTC before comparison
Three+ orders for same patient+device+DOS Sum quantities, combine all order numbers
Same patient, same DOS, different device Different device = different CoverageLine; no dedup conflict
Same patient, same device, different DOS Different DOS = different MergedShipment; no dedup conflict

8. Implementation Notes for Claude

Where the change lives

Layer File Change
Dedup step NEW: python-backend/core/dedup.py Group rows → MergedShipment
Pipeline orchestration python-backend/core/coverage_calculator.py Call dedup BEFORE gradeLine, pass MergedShipments instead of raw rows
Data model python-backend/core/coverage_calculator.py Add MergedShipment dataclass, add CoverageLine dataclass
Normalizer python-backend/api/normalizer.py No change — still normalizes individual rows; dedup happens after
Frontend signal-ui/src/components/WorklistTable.jsx Display order_numbers as list, components as set, quantity as sum

Test scenarios to cover

def test_dedup_component_split():
    """Two rows, same patient+device+DOS, different components → one MergedShipment."""
    rows = [
        CSVRow(patient_id="P1", device="dexcom_g7", dos="2026-06-01", qty=3, component="sensor"),
        CSVRow(patient_id="P1", device="dexcom_g7", dos="2026-06-01", qty=1, component="transmitter"),
    ]
    result = dedup(rows)
    assert len(result) == 1
    assert result[0].total_quantity == 4
    assert set(result[0].components) == {"sensor", "transmitter"}
    assert len(result[0].order_numbers) == 2  # Two different orders

def test_dedup_identical_duplicate():
    """Two rows, identical data → max quantity, not sum (duplicate guard)."""
    rows = [
        CSVRow(patient_id="P1", device="dexcom_g7", dos="2026-06-01", qty=3, component="sensor"),
        CSVRow(patient_id="P1", device="dexcom_g7", dos="2026-06-01", qty=3, component="sensor"),
    ]
    result = dedup(rows)
    assert len(result) == 1
    assert result[0].total_quantity == 3  # max, not 6

def test_dedup_different_dos():
    """Same patient+device, different DOS → two separate shipments."""
    rows = [
        CSVRow(patient_id="P1", device="dexcom_g7", dos="2026-06-01", qty=3, component="sensor"),
        CSVRow(patient_id="P1", device="dexcom_g7", dos="2026-07-01", qty=3, component="sensor"),
    ]
    result = dedup(rows)
    assert len(result) == 2

def test_dedup_different_device():
    """Same patient+DOS, different device → two separate shipments."""
    rows = [
        CSVRow(patient_id="P1", device="dexcom_g7", dos="2026-06-01", qty=3, component="sensor"),
        CSVRow(patient_id="P1", device="omnipod_5", dos="2026-06-01", qty=10, component="pod"),
    ]
    result = dedup(rows)
    assert len(result) == 2

def test_dedup_no_quantity():
    """Missing quantity → default to 1."""
    rows = [
        CSVRow(patient_id="P1", device="dexcom_g7", dos="2026-06-01", qty=None, component="sensor"),
    ]
    result = dedup(rows)
    assert result[0].total_quantity == 1

def test_dedup_missing_order_number():
    """No order_number in CSV → order_numbers list is empty, no crash."""
    rows = [
        CSVRow(patient_id="P1", device="dexcom_g7", dos="2026-06-01", qty=3, component="sensor", order_number=None),
    ]
    result = dedup(rows)
    assert result[0].order_numbers == []