feat(sp6): 4-field weighted scoring module
This commit is contained in:
@@ -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),
|
||||
)
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user