From 3432c765610a1b62eea9bd3ed6ff99e3d1a3bb6e Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 18:30:05 -0600 Subject: [PATCH] feat(sp6): 4-field weighted scoring module --- backend/src/cyclone/scoring.py | 96 +++++++++++++++++++++++++ backend/tests/test_scoring.py | 125 +++++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 backend/src/cyclone/scoring.py create mode 100644 backend/tests/test_scoring.py diff --git a/backend/src/cyclone/scoring.py b/backend/src/cyclone/scoring.py new file mode 100644 index 0000000..e28cab1 --- /dev/null +++ b/backend/src/cyclone/scoring.py @@ -0,0 +1,96 @@ +"""Transparent 4-field weighted scoring for Inbox candidate pairs. + +SP6 T5. + +Weights (sum to 100): + - patient control number match (exact, normalized) 40 + - service date proximity (±3 days linear decay) 25 + - charge amount proximity (±10% linear decay) 20 + - provider NPI exact match 15 +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from decimal import Decimal +from typing import Protocol + + +class _ScorableClaim(Protocol): + patient_control_number: str + service_date_from: date | None + charge_amount: Decimal + provider_npi: str | None + + +class _ScorableRemit(Protocol): + patient_control_number: str + service_date: date | None + charge_amount: Decimal + rendering_provider_npi: str | None + + +@dataclass(frozen=True) +class ScoreBreakdown: + patient: float + date: float + amount: float + provider: float + + @property + def total(self) -> int: + return round( + self.patient * 40 + + self.date * 25 + + self.amount * 20 + + self.provider * 15 + ) + + @property + def tier(self) -> str: + if self.total >= 75: + return "strong" + if self.total >= 50: + return "weak" + return "hidden" + + +def _norm_pcn(s: str) -> str: + return (s or "").strip().lower().lstrip("0") + + +def _date_score(claim_date: date | None, remit_date: date | None) -> float: + if claim_date is None or remit_date is None: + return 0.0 + delta = abs((claim_date - remit_date).days) + if delta > 3: + return 0.0 + return 1.0 - (delta / 3.0) + + +def _amount_score(claim_amt: Decimal, remit_amt: Decimal) -> float: + if claim_amt <= 0: + return 0.0 + diff = abs(claim_amt - remit_amt) + threshold = claim_amt * Decimal("0.10") + if diff > threshold: + return 0.0 + # Normalize diff within threshold to 0..1 + return float(Decimal("1.0") - (diff / threshold)) + + +def _provider_score(claim_npi: str | None, remit_npi: str | None) -> float: + if not claim_npi or not remit_npi: + return 0.0 + return 1.0 if claim_npi.strip() == remit_npi.strip() else 0.0 + + +def score_pair(claim: _ScorableClaim, remit: _ScorableRemit) -> ScoreBreakdown: + claim_pcn = _norm_pcn(claim.patient_control_number) + remit_pcn = _norm_pcn(remit.patient_control_number) + return ScoreBreakdown( + patient=1.0 if claim_pcn and claim_pcn == remit_pcn else 0.0, + date=_date_score(claim.service_date_from, remit.service_date), + amount=_amount_score(claim.charge_amount, remit.charge_amount), + provider=_provider_score(claim.provider_npi, remit.rendering_provider_npi), + ) diff --git a/backend/tests/test_scoring.py b/backend/tests/test_scoring.py new file mode 100644 index 0000000..03022a4 --- /dev/null +++ b/backend/tests/test_scoring.py @@ -0,0 +1,125 @@ +"""TDD: 4-field weighted scoring for Inbox candidate pairs. + +Plan: docs/superpowers/plans/2026-06-20-cyclone-workflow-automation.md +Task 5 (T5). +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from decimal import Decimal + +import pytest + +from cyclone.scoring import score_pair + + +@dataclass +class FakeClaim: + patient_control_number: str = "PCN-001" + service_date_from: date | None = date(2026, 6, 10) + charge_amount: Decimal = Decimal("100.00") + provider_npi: str | None = "1234567890" + + +@dataclass +class FakeRemit: + patient_control_number: str = "PCN-001" + service_date: date | None = date(2026, 6, 10) + charge_amount: Decimal = Decimal("100.00") + rendering_provider_npi: str | None = "1234567890" + + +def test_perfect_match_scores_100(): + s = score_pair(FakeClaim(), FakeRemit()) + assert s.total == 100 + assert s.patient == 1.0 + assert s.date == 1.0 + assert s.amount == 1.0 + assert s.provider == 1.0 + + +def test_date_within_3_days_scores_partial(): + s = score_pair( + FakeClaim(service_date_from=date(2026, 6, 10)), + FakeRemit(service_date=date(2026, 6, 12)), + ) + assert s.date == pytest.approx(1 - 2/3) # 0.333 + + +def test_date_beyond_3_days_scores_zero(): + s = score_pair( + FakeClaim(service_date_from=date(2026, 6, 10)), + FakeRemit(service_date=date(2026, 6, 14)), + ) + assert s.date == 0.0 + + +def test_amount_within_10pct_scores_partial(): + s = score_pair( + FakeClaim(charge_amount=Decimal("100.00")), + FakeRemit(charge_amount=Decimal("108.00")), + ) + assert s.amount == pytest.approx(1 - 8/10) + + +def test_amount_beyond_10pct_scores_zero(): + s = score_pair( + FakeClaim(charge_amount=Decimal("100.00")), + FakeRemit(charge_amount=Decimal("120.00")), + ) + assert s.amount == 0.0 + + +def test_patient_normalized_exact_match(): + s = score_pair( + FakeClaim(patient_control_number="PCN-001"), + FakeRemit(patient_control_number=" pcn-001 "), + ) + assert s.patient == 1.0 + + +def test_patient_mismatch_scores_zero(): + s = score_pair( + FakeClaim(patient_control_number="PCN-001"), + FakeRemit(patient_control_number="PCN-002"), + ) + assert s.patient == 0.0 + + +def test_missing_provider_npi_scores_zero(): + s = score_pair( + FakeClaim(provider_npi=None), + FakeRemit(rendering_provider_npi="1234567890"), + ) + assert s.provider == 0.0 + + +def test_missing_service_date_scores_zero(): + s = score_pair( + FakeClaim(service_date_from=None), + FakeRemit(service_date=date(2026, 6, 10)), + ) + assert s.date == 0.0 + + +def test_threshold_boundaries(): + """Tier boundaries: ≥75 strong, 50–74 weak, <50 hidden.""" + # All fields 0 except amount = 0.75 (exact) → 15 pts → total 15 → hidden + s = score_pair( + FakeClaim( + patient_control_number="X", # 0 + service_date_from=date(2026, 6, 10), + charge_amount=Decimal("100.00"), + provider_npi="1234567890", + ), + FakeRemit( + patient_control_number="Y", # 0 + service_date=date(2026, 6, 10), + charge_amount=Decimal("100.00"), + rendering_provider_npi="1234567890", + ), + ) + # patient=0, date=1, amount=1, provider=1 → 0+25+20+15 = 60 → weak tier + assert 50 <= s.total < 75 # weak tier + assert s.tier == "weak"