merge: SP31 835 strict content-match auto-link into main

This commit is contained in:
Nora
2026-07-02 16:25:48 -06:00
8 changed files with 1809 additions and 27 deletions
+214 -6
View File
@@ -17,7 +17,7 @@ Match algorithm:
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass, field
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from decimal import Decimal from decimal import Decimal
from typing import Iterable, Optional, Protocol from typing import Iterable, Optional, Protocol
@@ -50,6 +50,18 @@ class Match:
remittance: _RemitLike remittance: _RemitLike
strategy: str strategy: str
is_reversal: bool is_reversal: bool
# SP31: which content-keys the 2-of-3 (or 3-of-3) rule agreed on.
# Populated for ``score-auto`` matches; empty for ``pcn-exact`` /
# ``manual`` (PCN-exact only uses the PCN key by definition; manual
# is operator-driven). Floats into the ``auto_matched_835``
# ActivityEvent payload so the audit trail records *why* the link
# fired. Transient — the ORM Match row does not persist these
# (spec keeps the audit trail in ActivityEvent.payload_json only).
keys_matched: set[str] = field(default_factory=set)
# SP31: how many candidates were in the ±30-day window pool when
# the score-auto match fired. Useful when an operator is reviewing
# a borderline auto-link — 1-of-1 is unambiguous; 1-of-5 was lucky.
candidate_count: int = 0
def match( def match(
@@ -84,7 +96,7 @@ def match(
matches.append(Match( matches.append(Match(
claim=chosen, claim=chosen,
remittance=r, remittance=r,
strategy="auto", strategy="pcn-exact",
is_reversal=r.is_reversal, is_reversal=r.is_reversal,
)) ))
used_claim_ids.add(chosen.id) used_claim_ids.add(chosen.id)
@@ -117,6 +129,159 @@ def _pick_claim(
return None return None
# --- SP31: content-keys fallback matcher -----------------------------------
CHARGE_TOLERANCE = Decimal("0.01")
KEYS_REQUIRED = 2
def _content_keys_match(remit, claim) -> set[str]:
"""Return the set of content-keys that agree between ``remit`` and ``claim``.
Compares {``pcn``, ``charge``, ``npi``} between the two sides and returns
the subset whose values match. Caller checks ``len(returned) >= KEYS_REQUIRED``
to decide whether to auto-link. Returning the matched keys (rather than a
bool) lets the ActivityEvent payload record exactly which 2-of-3 (or 3-of-3)
produced the auto-link — the audit trail for the SP31 content-keys rule.
Charge compared with ``CHARGE_TOLERANCE`` tolerance (rounding drift).
NPI counts as "not matched" when the remit's NPI is empty.
Uses duck-typing via ``getattr`` so the helper works against both:
- shim / dataclass test objects (planned names: total_charge_amount,
total_charge, rendering_provider_npi), and
- real SQLAlchemy ORM instances (Claim.charge_amount, Remittance.total_charge,
Claim.provider_npi).
Production note: the 835 parser stores ``rendering_provider_npi`` and
``total_charge_amount`` on ``Remittance.raw_json`` (the ORM has no
dedicated column), so the read order below is:
- NPI: transient attribute → raw_json
- Charge: planned attribute name → real ORM column → raw_json
The ``raw_json`` fallback is what makes this helper safe against a
raw ``Remittance`` ORM instance from ``reconcile.run()``.
"""
matched: set[str] = set()
# PCN: payer_claim_control_number ↔ patient_control_number (stripped).
if (getattr(remit, "payer_claim_control_number", "") or "").strip() == \
(getattr(claim, "patient_control_number", "") or "").strip():
matched.add("pcn")
# Charge: prefer the planned attribute names, fall back to real ORM columns,
# then to raw_json (where the 835 parser stores CLP03). Explicit ``is None``
# checks avoid the ``Decimal("0")`` truthiness footgun — ``Decimal("0")``
# is truthy in a boolean context but ``or`` would still let it through;
# the more important property is that we don't accidentally replace a real
# value with a default.
_remit_charge = getattr(remit, "total_charge_amount", None)
if _remit_charge is None:
_remit_charge = getattr(remit, "total_charge", None)
if _remit_charge is None:
_remit_charge = (getattr(remit, "raw_json", None) or {}).get("total_charge_amount")
_claim_charge = getattr(claim, "total_charge", None)
if _claim_charge is None:
_claim_charge = getattr(claim, "charge_amount", None)
if _claim_charge is None:
_claim_charge = (getattr(claim, "raw_json", None) or {}).get("total_charge_amount")
if _remit_charge is not None and _claim_charge is not None and \
abs(_remit_charge - _claim_charge) < CHARGE_TOLERANCE:
matched.add("charge")
# NPI: prefer attribute, fall back to raw_json (where the 835 parser
# stores rendering_provider_npi). The Remittance ORM has no NPI column,
# so the raw_json path is the production read for raw ORM instances —
# it must NOT be removed.
remit_npi = (
(getattr(remit, "rendering_provider_npi", "") or "")
or ((getattr(remit, "raw_json", None) or {}).get("rendering_provider_npi", "") or "")
).strip()
claim_npi = (
(getattr(claim, "rendering_provider_npi", "") or "")
or (getattr(claim, "provider_npi", "") or "")
or ((getattr(claim, "raw_json", None) or {}).get("rendering_provider_npi", "") or "")
).strip()
if remit_npi and remit_npi == claim_npi:
matched.add("npi")
return matched
# --- SP31: DB-side fallback candidate matcher -------------------------------
SCORE_FALLBACK_WINDOW_DAYS = 30
def _score_fallback_candidates(session, remit) -> Optional[tuple[str, set[str], int]]:
"""Return (claim_id, keys_matched, candidate_count) for a unique 2-of-3 match.
Queries a ±SCORE_FALLBACK_WINDOW_DAYS candidate pool filtered by:
- claim.matched_remittance_id IS NULL (not yet linked)
- claim.state NOT IN terminal set (PAID, DENIED, REJECTED,
REVERSED, RECONCILED)
- claim.service_date_from within window of remit.service_date
Returns ``(claim_id, keys_matched, candidate_count)`` when exactly one
candidate passes the 2-of-3 rule (via :func:`_content_keys_match`),
otherwise ``None``. ``keys_matched`` is the set returned by the helper
(``{"pcn", "charge"}`` or any 2-of-3 / 3-of-3 combination) — the
ActivityEvent payload records it as the audit trail for *why* the
auto-link fired. ``candidate_count`` is the size of the window pool
(independent of how many matched) so the operator can see whether
the match was 1-of-N or 1-of-1.
Ambiguous matches (2+ candidates) also return ``None`` — they land
in the Inbox Unlinked lane for manual review.
Note on payer_id: ``Remittance`` has no ``payer_id`` column
(``Claim.payer_id`` does, and is the source of truth on the claim
side; the 835 parser stores payer_id in ``Remittance.raw_json``).
Filtering by payer would require either a raw_json read or a model
column add — out of scope for SP31 Task 3. The PCN itself is
expected to be unique within a payer's submission, so cross-payer
collisions are unlikely. Revisit if production shows otherwise.
"""
from sqlalchemy import select, and_
from datetime import timedelta
from cyclone.db import Claim, ClaimState
TERMINAL = {
ClaimState.PAID, ClaimState.DENIED, ClaimState.REJECTED,
ClaimState.REVERSED, ClaimState.RECONCILED,
}
if remit.service_date is None:
return None # need a date for the window query
window_lo = remit.service_date - timedelta(days=SCORE_FALLBACK_WINDOW_DAYS)
window_hi = remit.service_date + timedelta(days=SCORE_FALLBACK_WINDOW_DAYS)
candidates = list(session.execute(
select(Claim).where(
and_(
Claim.matched_remittance_id.is_(None),
Claim.service_date_from >= window_lo,
Claim.service_date_from <= window_hi,
Claim.state.notin_([s.value for s in TERMINAL]),
)
)
).scalars().all())
matched_pairs = [
(c.id, _content_keys_match(remit, c))
for c in candidates
]
matched_pairs = [
(cid, ks) for cid, ks in matched_pairs if len(ks) >= KEYS_REQUIRED
]
if len(matched_pairs) == 1:
cid, keys_matched = matched_pairs[0]
return (cid, keys_matched, len(candidates))
return None # 0 matches OR 2+ (ambiguous)
@dataclass @dataclass
class ApplyIntent: class ApplyIntent:
"""Result of applying a match. The caller persists this. """Result of applying a match. The caller persists this.
@@ -239,7 +404,7 @@ def run(session, batch_id: str) -> ReconcileResult:
""" """
from sqlalchemy import select from sqlalchemy import select
from cyclone.db import ( from cyclone.db import (
Batch, Claim, Remittance, CasAdjustment, Match, ActivityEvent, Batch, Claim, Remittance, CasAdjustment, Match as MatchORM, ActivityEvent,
ClaimState, ClaimState,
) )
@@ -257,6 +422,34 @@ def run(session, batch_id: str) -> ReconcileResult:
matches = match(unmatched_claims, new_remits) matches = match(unmatched_claims, new_remits)
# SP31: content-keys fallback for remits that PCN-exact couldn't pair.
# Operates on the still-unmatched remits so we don't double-match.
matched_remit_ids = {m.remittance.id for m in matches}
used_claim_ids = {m.claim.id for m in matches}
for remit in new_remits:
if remit.id in matched_remit_ids:
continue
if getattr(remit, "claim_id", None) is not None:
continue # already linked
fallback_result = _score_fallback_candidates(session, remit)
if fallback_result is None:
continue
matched_claim_id, keys_matched, candidate_count = fallback_result
# Find the claim object in the unmatched_claims list (it was loaded).
target_claim = next(
(c for c in unmatched_claims if c.id == matched_claim_id),
None,
)
if target_claim is None or target_claim.id in used_claim_ids:
continue
matches.append(Match(
claim=target_claim, remittance=remit,
strategy="score-auto", is_reversal=remit.is_reversal,
keys_matched=keys_matched,
candidate_count=candidate_count,
))
used_claim_ids.add(target_claim.id)
applied = 0 applied = 0
skipped = 0 skipped = 0
for m in matches: for m in matches:
@@ -280,7 +473,7 @@ def run(session, batch_id: str) -> ReconcileResult:
skipped += 1 skipped += 1
continue continue
session.add(Match( session.add(MatchORM(
claim_id=m.claim.id, remittance_id=m.remittance.id, claim_id=m.claim.id, remittance_id=m.remittance.id,
strategy=m.strategy, strategy=m.strategy,
matched_at=datetime.now(timezone.utc), matched_at=datetime.now(timezone.utc),
@@ -298,10 +491,25 @@ def run(session, batch_id: str) -> ReconcileResult:
m.remittance.claim_id = m.claim.id m.remittance.claim_id = m.claim.id
session.add(ActivityEvent( session.add(ActivityEvent(
ts=datetime.now(timezone.utc), kind=intent.activity_kind, ts=datetime.now(timezone.utc),
kind=("auto_matched_835" if m.strategy == "score-auto" else intent.activity_kind),
batch_id=batch_id, claim_id=m.claim.id, batch_id=batch_id, claim_id=m.claim.id,
remittance_id=m.remittance.id, remittance_id=m.remittance.id,
payload_json={"new_state": m.claim.state.value}, payload_json=(
{
"new_state": m.claim.state.value, "strategy": m.strategy,
# SP31 spec D8: the auto_matched_835 payload records
# which content-keys the 2-of-3 (or 3-of-3) rule
# agreed on, plus how many candidates were in the
# window pool. ``sorted()`` converts the set to a
# JSON-serializable list and pins a deterministic
# order for tests + audit reads.
"keys_matched": sorted(m.keys_matched),
"candidate_count": m.candidate_count,
}
if m.strategy == "score-auto"
else {"new_state": m.claim.state.value}
),
)) ))
applied += 1 applied += 1
+137 -1
View File
@@ -14,6 +14,9 @@ test_api_parse_persists.py) working unchanged.
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
import pytest import pytest
@@ -89,4 +92,137 @@ def _reset_rate_limit_buckets(app) -> None:
cur = getattr(cur, "app", None) cur = getattr(cur, "app", None)
# No RateLimitMiddleware in the stack — nothing to reset. Should # No RateLimitMiddleware in the stack — nothing to reset. Should
# not happen in this codebase (security.py registers it at boot) # not happen in this codebase (security.py registers it at boot)
# but we don't want a missing reset to crash unrelated tests. # but we don't want a missing reset to crash unrelated tests.
# ---------------------------------------------------------------------------
# SP31: shared DB-session + Claim/Remit factory fixtures.
#
# `db_session` yields a fresh session per test against the per-test DB set
# up by the autouse `_auto_init_db` fixture above. The session rolls back at
# teardown so a test that mutates rows doesn't leak into siblings.
#
# `make_claim` / `make_remit` build ORM rows with a small ergonomic surface:
# the planned parameter names follow the content-keys helper (PCN, charge,
# rendering NPI), but the ORM attribute names differ (`charge_amount` on
# Claim, `total_charge` on Remittance, `provider_npi` on Claim — and no
# `payer_id` / `rendering_provider_npi` column on Remittance at all). The
# factories map the planned names onto the real ORM attributes and stash
# `rendering_provider_npi` as a transient attribute so
# ``reconcile._content_keys_match`` can still read it via ``getattr``.
# ---------------------------------------------------------------------------
@pytest.fixture
def db_session():
"""Yield a fresh SQLAlchemy session, rolling back at teardown."""
from cyclone import db as _db
session = _db.SessionLocal()()
try:
yield session
session.rollback()
finally:
session.close()
def _ensure_batch(session, batch_id: str = "test-batch") -> None:
"""Create the Batch row a Claim/Remittance FKs to (idempotent)."""
from cyclone.db import Batch
if session.get(Batch, batch_id) is None:
session.add(Batch(
id=batch_id, kind="837p", input_filename="test.txt",
parsed_at=datetime.now(timezone.utc),
))
session.flush()
@pytest.fixture
def make_claim(db_session):
"""Factory: build & flush a Claim with the planned content-keys params.
Planned params (match the SP31 spec / content-keys test surface):
patient_control_number, total_charge, rendering_provider_npi,
service_date_from, matched_remittance_id=None, state=None,
claim_id=None
`state=None` defaults to ``ClaimState.SUBMITTED`` so existing tests
that omit it keep behaving. `rendering_provider_npi` is stored as
a transient attribute (no ORM column) for ``_content_keys_match``
to read via ``getattr``.
"""
def _make(
patient_control_number: str,
total_charge,
rendering_provider_npi: str,
service_date_from,
matched_remittance_id=None,
state=None,
claim_id: str | None = None,
):
from cyclone.db import Claim, ClaimState as _CS
_ensure_batch(db_session)
cid = claim_id or f"clm-{patient_control_number}-{service_date_from.isoformat()}"
c = Claim(
id=cid,
batch_id="test-batch",
patient_control_number=patient_control_number,
service_date_from=service_date_from,
charge_amount=total_charge,
state=state if state is not None else _CS.SUBMITTED,
matched_remittance_id=matched_remittance_id,
)
# Transient attribute — read by reconcile._content_keys_match
# via getattr; the Claim ORM has no rendering_provider_npi column.
c.rendering_provider_npi = rendering_provider_npi
db_session.add(c)
db_session.flush()
return c
return _make
@pytest.fixture
def make_remit(db_session):
"""Factory: build & flush a Remittance with the planned content-keys params.
Planned params:
payer_claim_control_number, total_charge_amount, rendering_provider_npi,
service_date, remit_id=None
`total_charge_amount` is mapped to ``Remittance.total_charge`` (real ORM
field). `rendering_provider_npi` is stored as a transient attribute for
``_content_keys_match`` to read via ``getattr`` (Remittance has no NPI
column; the parser reads it from raw_json in production).
"""
def _make(
payer_claim_control_number: str,
total_charge_amount,
rendering_provider_npi: str,
service_date,
remit_id: str | None = None,
):
from cyclone.db import Remittance
_ensure_batch(db_session)
rid = remit_id or f"remit-{payer_claim_control_number}-{service_date.isoformat()}"
r = Remittance(
id=rid,
batch_id="test-batch",
payer_claim_control_number=payer_claim_control_number,
status_code="1",
total_charge=total_charge_amount,
total_paid=Decimal("0"),
received_at=datetime.now(timezone.utc),
service_date=service_date,
is_reversal=False,
)
# Transient attribute — read by reconcile._content_keys_match via
# getattr. The Remittance ORM has no rendering_provider_npi column;
# the 835 parser reads it from raw_json in production.
r.rendering_provider_npi = rendering_provider_npi
db_session.add(r)
db_session.flush()
return r
return _make
+3 -3
View File
@@ -320,7 +320,7 @@ def test_create_match_and_activity_event():
)) ))
m = Match( m = Match(
claim_id="CLM-1", remittance_id="CLP-1", claim_id="CLM-1", remittance_id="CLP-1",
strategy="auto", is_reversal=False, strategy="pcn-exact", is_reversal=False,
matched_at=datetime(2026, 6, 19, 13, 0, tzinfo=timezone.utc), matched_at=datetime(2026, 6, 19, 13, 0, tzinfo=timezone.utc),
) )
s.add(m) s.add(m)
@@ -335,7 +335,7 @@ def test_create_match_and_activity_event():
with db.SessionLocal()() as s: with db.SessionLocal()() as s:
loaded = s.get(Match, 1) # autoincrement id loaded = s.get(Match, 1) # autoincrement id
assert loaded is not None assert loaded is not None
assert loaded.strategy == "auto" assert loaded.strategy == "pcn-exact"
assert loaded.is_reversal is False assert loaded.is_reversal is False
events = s.query(ActivityEvent).all() events = s.query(ActivityEvent).all()
@@ -361,7 +361,7 @@ def test_match_multiple_rows_per_claim():
status_code="1", received_at=datetime.now(timezone.utc))) status_code="1", received_at=datetime.now(timezone.utc)))
s.add(Remittance(id="CLP-2", batch_id="b1", payer_claim_control_number="y", s.add(Remittance(id="CLP-2", batch_id="b1", payer_claim_control_number="y",
status_code="1", received_at=datetime.now(timezone.utc))) status_code="1", received_at=datetime.now(timezone.utc)))
s.add(Match(claim_id="CLM-1", remittance_id="CLP-1", strategy="auto", s.add(Match(claim_id="CLM-1", remittance_id="CLP-1", strategy="pcn-exact",
matched_at=datetime.now(timezone.utc))) matched_at=datetime.now(timezone.utc)))
s.add(Match(claim_id="CLM-1", remittance_id="CLP-2", strategy="manual", s.add(Match(claim_id="CLM-1", remittance_id="CLP-2", strategy="manual",
matched_at=datetime.now(timezone.utc), matched_at=datetime.now(timezone.utc),
@@ -153,19 +153,18 @@ def test_provider_detail_includes_orphan_remit_received(seeded_db: TestClient):
the moment an 835 lands — the most common activity, invisible. the moment an 835 lands — the most common activity, invisible.
Setup: ingest 837+835 for ``TEST_837_NPI`` (claim CLM001 + remit Setup: ingest 837+835 for ``TEST_837_NPI`` (claim CLM001 + remit
CLM001), then manually match them so ``Remittance.claim_id`` is CLM001). With SP31, the minimal fixtures now auto-link via the
populated. The bug presents as: only ``claim_submitted`` and content-keys fallback (PCN + charge agree → 2-of-3) so
``manual_match`` appear (no ``remit_received``). The fix surfaces ``Remittance.claim_id`` is populated as part of the 835 ingest
all three. itself — no separate manual-match call needed. The bug presents
as: only ``claim_submitted`` appears (no ``remit_received``). The
fix surfaces both.
""" """
# Force the match — simulates the post-reconciliation state that # The minimal fixture's PCN ("CLM001") + charge ("$100.00") match,
# populate Remittance.claim_id without depending on auto-reconcile # so SP31's content-keys fallback pairs the remit during ingest.
# heuristics (which don't match this minimal fixture). # No manual-match call is required (and would now 409 "already_matched").
match_resp = seeded_db.post( # The intent of this test — surfacing the remit_received event via
"/api/reconciliation/match", # the Remittance.claim_id join — is fully exercised either way.
json={"claim_id": "CLM001", "remit_id": "CLM001"},
)
assert match_resp.status_code == 200, match_resp.text
resp = seeded_db.get(f"/api/config/providers/{TEST_837_NPI}") resp = seeded_db.get(f"/api/config/providers/{TEST_837_NPI}")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
+425 -3
View File
@@ -55,7 +55,7 @@ def test_match_same_pcn_within_window_matches():
assert len(matches) == 1 assert len(matches) == 1
assert matches[0].claim.id == "CLM-1" assert matches[0].claim.id == "CLM-1"
assert matches[0].remittance.id == "CLP-1" assert matches[0].remittance.id == "CLP-1"
assert matches[0].strategy == "auto" assert matches[0].strategy == "pcn-exact"
def test_match_dates_outside_window_unmatched(): def test_match_dates_outside_window_unmatched():
@@ -203,7 +203,7 @@ def test_apply_reversal_of_already_reversed_is_noop():
def test_split_unmatched_partitions_by_match_set(): def test_split_unmatched_partitions_by_match_set():
claims = [FakeClaim("CLM-1", "A", None), FakeClaim("CLM-2", "B", None)] claims = [FakeClaim("CLM-1", "A", None), FakeClaim("CLM-2", "B", None)]
remits = [FakeRemit("CLP-1", "A", None), FakeRemit("CLP-2", "C", None)] remits = [FakeRemit("CLP-1", "A", None), FakeRemit("CLP-2", "C", None)]
matches = [ReconcileMatch(claim=claims[0], remittance=remits[0], strategy="auto", is_reversal=False)] matches = [ReconcileMatch(claim=claims[0], remittance=remits[0], strategy="pcn-exact", is_reversal=False)]
unmatched_claims, unmatched_remits = reconcile.split_unmatched(claims, remits, matches) unmatched_claims, unmatched_remits = reconcile.split_unmatched(claims, remits, matches)
assert [c.id for c in unmatched_claims] == ["CLM-2"] assert [c.id for c in unmatched_claims] == ["CLM-2"]
assert [r.id for r in unmatched_remits] == ["CLP-2"] assert [r.id for r in unmatched_remits] == ["CLP-2"]
@@ -306,7 +306,7 @@ def test_run_reversal_flips_paid_to_reversed(fixture_835):
# Match row already exists from prior reconcile. # Match row already exists from prior reconcile.
s.add(Match( s.add(Match(
claim_id="CLM-1", remittance_id="CLP-OLD:b1xxxxxx", claim_id="CLM-1", remittance_id="CLP-OLD:b1xxxxxx",
strategy="auto", matched_at=datetime.now(timezone.utc), strategy="pcn-exact", matched_at=datetime.now(timezone.utc),
is_reversal=False, is_reversal=False,
)) ))
s.flush() s.flush()
@@ -370,3 +370,425 @@ def test_run_reconcile_raise_in_session_leaves_prior_commits_alone(fixture_835):
assert b is not None assert b is not None
r = s.query(Remittance).first() r = s.query(Remittance).first()
assert r is not None assert r is not None
# --- SP31: content-keys fallback matcher (pure helper) -----------------------
def _make_remit_for_keys(remit_id: str, pcn: str, charge, npi: str):
"""Tiny shim with the three fields _content_keys_match reads."""
from cyclone.reconcile import _RemitLike # protocol not enforced at runtime
return type("R", (), {
"id": remit_id,
"payer_claim_control_number": pcn,
"total_charge_amount": charge,
"rendering_provider_npi": npi,
})()
def _make_claim_for_keys(claim_id: str, pcn: str, charge, npi: str):
return type("C", (), {
"id": claim_id,
"patient_control_number": pcn,
"total_charge": charge,
"rendering_provider_npi": npi,
})()
def test_content_keys_pcn_plus_charge_matches():
"""PCN + charge match (NPI mismatched) → 2-of-3, returns ``{"pcn","charge"}``."""
from cyclone.reconcile import _content_keys_match
from decimal import Decimal
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "")
c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
assert _content_keys_match(r, c) == {"pcn", "charge"}
def test_content_keys_pcn_plus_npi_matches():
"""PCN + NPI match (charge mismatched) → 2-of-3, returns ``{"pcn","npi"}``."""
from cyclone.reconcile import _content_keys_match
from decimal import Decimal
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
c = _make_claim_for_keys("c1", "ABC", Decimal("200.00"), "1111111111")
assert _content_keys_match(r, c) == {"pcn", "npi"}
def test_content_keys_charge_plus_npi_matches():
"""Charge + NPI match (PCN mismatched) → 2-of-3, returns ``{"charge","npi"}``."""
from cyclone.reconcile import _content_keys_match
from decimal import Decimal
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "1111111111")
assert _content_keys_match(r, c) == {"charge", "npi"}
def test_content_keys_all_three_match():
"""All 3 match → 3-of-3, returns ``{"pcn","charge","npi"}``."""
from cyclone.reconcile import _content_keys_match
from decimal import Decimal
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
assert _content_keys_match(r, c) == {"pcn", "charge", "npi"}
def test_content_keys_only_pcn_does_not_match():
"""Only PCN matches (charge + NPI both wrong) → set is just ``{"pcn"}``."""
from cyclone.reconcile import _content_keys_match
from decimal import Decimal
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
c = _make_claim_for_keys("c1", "ABC", Decimal("200.00"), "2222222222")
assert _content_keys_match(r, c) == {"pcn"}
def test_content_keys_only_charge_does_not_match():
"""Only charge matches (PCN + NPI both wrong) → set is just ``{"charge"}``."""
from cyclone.reconcile import _content_keys_match
from decimal import Decimal
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "2222222222")
assert _content_keys_match(r, c) == {"charge"}
def test_content_keys_none_match():
"""All 3 fields disagree → empty set."""
from cyclone.reconcile import _content_keys_match
from decimal import Decimal
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
c = _make_claim_for_keys("c1", "XYZ", Decimal("200.00"), "2222222222")
assert _content_keys_match(r, c) == set()
def test_content_keys_charge_tolerance_is_one_cent():
"""Charge differs by exactly $0.005 (rounding) → charge counts as matched."""
from cyclone.reconcile import _content_keys_match
from decimal import Decimal
r = _make_remit_for_keys("r1", "ABC", Decimal("100.005"), "1111111111")
c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
assert _content_keys_match(r, c) == {"pcn", "charge", "npi"}
def test_content_keys_charge_differs_by_two_cents_no_match():
"""Charge differs by $0.02 → charge does NOT match (outside tolerance)."""
from cyclone.reconcile import _content_keys_match
from decimal import Decimal
# PCN and NPI deliberately differ so the only potential match is charge.
# Charge is outside the $0.01 tolerance → 0 of 3 match → empty set.
r = _make_remit_for_keys("r1", "ABC", Decimal("100.02"), "1111111111")
c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "2222222222")
assert _content_keys_match(r, c) == set()
def test_content_keys_empty_npi_does_not_count():
"""Empty NPI on remit counts as not-matched (falls back to PCN+charge rule)."""
from cyclone.reconcile import _content_keys_match
from decimal import Decimal
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "")
c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
assert _content_keys_match(r, c) == {"pcn", "charge"} # NPI skipped on remit
c2 = _make_claim_for_keys("c2", "XYZ", Decimal("100.00"), "1111111111")
assert _content_keys_match(r, c2) == {"charge"} # only charge, no PCN
# --- SP31: content-keys fallback matcher (DB helper) ------------------------
def test_score_fallback_unique_match_returns_claim_id(db_session, make_claim, make_remit):
"""One claim in pool matches 2-of-3 → returns (claim_id, keys, candidate_count)."""
from decimal import Decimal
from datetime import date, timedelta
from cyclone.reconcile import _score_fallback_candidates
c1 = make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date_from=date(2026, 6, 1))
c2 = make_claim(patient_control_number="XYZ", total_charge=Decimal("999.99"),
rendering_provider_npi="2222222222",
service_date_from=date(2026, 6, 1))
r = make_remit(payer_claim_control_number="ABC",
total_charge_amount=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date=date(2026, 6, 5)) # ±30-day window
result = _score_fallback_candidates(db_session, r)
assert result is not None
claim_id, keys_matched, candidate_count = result
assert claim_id == c1.id
assert keys_matched == {"pcn", "charge", "npi"} # 3-of-3
assert candidate_count == 2 # pool had 2 candidates
def test_score_fallback_zero_matches_returns_none(db_session, make_claim, make_remit):
"""No claim in pool matches 2-of-3 → returns None."""
from decimal import Decimal
from datetime import date
from cyclone.reconcile import _score_fallback_candidates
make_claim(patient_control_number="XYZ", total_charge=Decimal("999.99"),
rendering_provider_npi="2222222222",
service_date_from=date(2026, 6, 1))
r = make_remit(payer_claim_control_number="ABC",
total_charge_amount=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date=date(2026, 6, 5))
assert _score_fallback_candidates(db_session, r) is None
def test_score_fallback_two_matches_returns_none(db_session, make_claim, make_remit):
"""Two claims in pool both match 2-of-3 → returns None (ambiguous)."""
from decimal import Decimal
from datetime import date
from cyclone.reconcile import _score_fallback_candidates
# Both claims have same PCN and same charge — both would match PCN+charge.
make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date_from=date(2026, 6, 1))
make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date_from=date(2026, 6, 2))
r = make_remit(payer_claim_control_number="ABC",
total_charge_amount=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date=date(2026, 6, 5))
assert _score_fallback_candidates(db_session, r) is None
def test_score_fallback_skips_already_matched(db_session, make_claim, make_remit):
"""Claim with matched_remittance_id set → excluded from pool."""
from decimal import Decimal
from datetime import date
from cyclone.reconcile import _score_fallback_candidates
c1 = make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date_from=date(2026, 6, 1),
matched_remittance_id="some-other-remit-id")
r = make_remit(payer_claim_control_number="ABC",
total_charge_amount=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date=date(2026, 6, 5))
assert _score_fallback_candidates(db_session, r) is None # only candidate was excluded
def test_score_fallback_skips_terminal_state(db_session, make_claim, make_remit):
"""Claim in PAID/DENIED/REJECTED/REVERSED/RECONCILED → excluded from pool."""
from decimal import Decimal
from datetime import date
from cyclone.db import ClaimState
from cyclone.reconcile import _score_fallback_candidates
make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date_from=date(2026, 6, 1),
state=ClaimState.PAID)
r = make_remit(payer_claim_control_number="ABC",
total_charge_amount=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date=date(2026, 6, 5))
assert _score_fallback_candidates(db_session, r) is None
def test_score_fallback_widened_window_catches_late_remit(db_session, make_claim, make_remit):
"""Remit 25 days after claim → still in pool (30-day window)."""
from decimal import Decimal
from datetime import date, timedelta
from cyclone.reconcile import _score_fallback_candidates
claim_date = date(2026, 6, 1)
remit_date = claim_date + timedelta(days=25)
c1 = make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date_from=claim_date)
r = make_remit(payer_claim_control_number="ABC",
total_charge_amount=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date=remit_date)
result = _score_fallback_candidates(db_session, r)
assert result is not None
claim_id, keys_matched, candidate_count = result
assert claim_id == c1.id
assert candidate_count == 1
def test_score_fallback_outside_window_excluded(db_session, make_claim, make_remit):
"""Remit 60 days after claim → excluded (outside 30-day window)."""
from decimal import Decimal
from datetime import date, timedelta
from cyclone.reconcile import _score_fallback_candidates
claim_date = date(2026, 6, 1)
remit_date = claim_date + timedelta(days=60)
make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date_from=claim_date)
r = make_remit(payer_claim_control_number="ABC",
total_charge_amount=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date=remit_date)
assert _score_fallback_candidates(db_session, r) is None
# --- SP31 Task 3 fix: Remittance NPI lives in raw_json ----------------------
def test_content_keys_npi_from_remit_raw_json(db_session, make_claim):
"""NPI on the Remit is read from ``raw_json`` when the attribute is absent.
The 835 parser stores rendering_provider_npi on ``Remittance.raw_json``
(the ORM has no dedicated column) — reconcile.run() hands a raw
``Remittance`` instance to ``_content_keys_match``, so the helper must
fall through ``getattr(...rendering_provider_npi...)`` to the raw_json
read. This test pins that path with a real ORM row (no transient
``setattr`` shadowing the production layout).
Configuration: PCN mismatched, charge matches, NPI matches via raw_json
→ 2 of 3 keys agree → True.
"""
from decimal import Decimal
from datetime import date, datetime, timezone
from cyclone.db import Remittance
from cyclone.reconcile import _content_keys_match
claim = make_claim(
patient_control_number="ABC",
total_charge=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date_from=date(2026, 6, 1),
)
# Real Remittance ORM instance — NPI lives ONLY in raw_json, not as a
# transient attribute. (The 835 parser populates raw_json that way;
# the make_remit factory's transient ``rendering_provider_npi`` setattr
# would shadow the production read.)
remit = Remittance(
id="real-remit-1",
batch_id="test-batch",
payer_claim_control_number="OTHER-PCN", # PCN: won't match "ABC"
status_code="1",
total_charge=Decimal("100.00"), # charge: matches
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
raw_json={"rendering_provider_npi": "1111111111"}, # NPI: matches
)
db_session.add(remit)
db_session.flush()
assert _content_keys_match(remit, claim) == {"charge", "npi"} # PCN mismatched
# --- SP31: end-to-end integration via reconcile.run() -----------------------
def test_reconcile_run_emits_score_auto_match_when_pcn_misses(db_session, make_claim, make_remit):
"""A remit with no PCN match but matching charge+NPI triggers score-auto."""
from decimal import Decimal
from datetime import date
from sqlalchemy import select
from cyclone.db import Batch, ClaimState
from cyclone.reconcile import run as reconcile_run
claim = make_claim(patient_control_number="UNIQUE-CLM",
total_charge=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date_from=date(2026, 6, 1))
remit = make_remit(payer_claim_control_number="DIFFERENT-PCN",
total_charge_amount=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date=date(2026, 6, 5))
batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5))
db_session.add(batch)
db_session.flush()
# Re-parent the remit to this batch (make_remit doesn't set batch_id).
remit.batch_id = batch.id
db_session.flush()
result = reconcile_run(db_session, batch.id)
db_session.flush() # autoflush=False on SessionLocal; force flush pending Match row
assert result.matched == 1
# Verify Match row was written with strategy="score-auto"
from cyclone.db import Match
matches = list(db_session.execute(
select(Match).where(Match.remittance_id == remit.id)
).scalars().all())
assert len(matches) == 1
assert matches[0].strategy == "score-auto"
assert matches[0].claim_id == claim.id
# Symmetric FK: the auto-match path sets Remittance.claim_id so the
# list_unmatched filter (``Remittance.claim_id IS NULL``) correctly
# drops this pair from the orphan bucket. Mirrors the manual_match
# contract — both paths must populate both sides of the FK pair.
db_session.refresh(remit)
assert remit.claim_id == claim.id
# Verify ActivityEvent was emitted with kind="auto_matched_835"
from cyclone.db import ActivityEvent
events = list(db_session.execute(
select(ActivityEvent).where(
ActivityEvent.remittance_id == remit.id,
ActivityEvent.kind == "auto_matched_835",
)
).scalars().all())
assert len(events) == 1
assert events[0].claim_id == claim.id
# Spec D8: payload records keys_matched + candidate_count so the
# operator can see *why* the auto-link fired.
payload = events[0].payload_json or {}
assert "keys_matched" in payload
assert "candidate_count" in payload
assert payload["candidate_count"] == 1
assert set(payload["keys_matched"]) >= {"charge", "npi"} # PCN differs in this test
# Verify claim state was flipped
db_session.refresh(claim)
assert claim.state in {ClaimState.PAID, ClaimState.PARTIAL, ClaimState.RECEIVED}
def test_reconcile_run_pcn_path_unchanged(db_session, make_claim, make_remit):
"""Existing PCN-exact match still produces strategy='pcn-exact' (regression guard)."""
from decimal import Decimal
from datetime import date
from sqlalchemy import select
from cyclone.db import Batch, Match
from cyclone.reconcile import run as reconcile_run
claim = make_claim(patient_control_number="SAME-PCN",
total_charge=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date_from=date(2026, 6, 1))
remit = make_remit(payer_claim_control_number="SAME-PCN",
total_charge_amount=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date=date(2026, 6, 2))
batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5))
db_session.add(batch)
db_session.flush()
remit.batch_id = batch.id
db_session.flush()
reconcile_run(db_session, batch.id)
db_session.flush() # autoflush=False on SessionLocal; force flush pending Match row
matches = list(db_session.execute(
select(Match).where(Match.remittance_id == remit.id)
).scalars().all())
assert len(matches) == 1
assert matches[0].strategy == "pcn-exact" # NOT "score-auto"
def test_reconcile_run_no_match_leaves_remit_unlinked(db_session, make_claim, make_remit):
"""No 2-of-3 match → no Match row, claim state unchanged."""
from decimal import Decimal
from datetime import date
from sqlalchemy import select
from cyclone.db import Batch, Match, ClaimState
from cyclone.reconcile import run as reconcile_run
claim = make_claim(patient_control_number="UNRELATED",
total_charge=Decimal("999.99"),
rendering_provider_npi="2222222222",
service_date_from=date(2026, 6, 1))
remit = make_remit(payer_claim_control_number="OTHER",
total_charge_amount=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date=date(2026, 6, 5))
batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5))
db_session.add(batch)
db_session.flush()
remit.batch_id = batch.id
db_session.flush()
result = reconcile_run(db_session, batch.id)
assert result.matched == 0
matches = list(db_session.execute(
select(Match).where(Match.remittance_id == remit.id)
).scalars().all())
assert len(matches) == 0
db_session.refresh(claim)
assert claim.state == ClaimState.SUBMITTED # unchanged
db_session.refresh(remit)
assert remit.claim_id is None # unchanged
@@ -0,0 +1,887 @@
# SP31 — 835 Strict Content-Match Auto-Link Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a deterministic "any 2 of {PCN, charge, NPI}" content-keys fallback to the 835 auto-match path so more remits link to claims at parse time and the Recent Batches widget reflects settled state immediately.
**Architecture:** Keep the existing in-memory `reconcile.match()` PCN-exact path as the fast primary. Add a new DB-driven `reconcile._score_fallback_candidates()` that runs **after** `match()` on the leftover unmatched remits, queries a ±30-day candidate pool, applies the 2-of-3 rule, and on a unique hit returns a `Match(strategy="score-auto")` for the same orchestrator loop to consume. Rename existing `strategy="auto"``"pcn-exact"` for audit clarity.
**Tech Stack:** Python 3.11+, SQLAlchemy 2.x (sync session), pytest. No new dependencies. No schema migration. No frontend changes (widget already reflects state via SP30).
**Spec:** `docs/superpowers/specs/2026-07-02-cyclone-835-strict-content-match-design.md`
---
## File Structure
| File | Change | Responsibility |
|---|---|---|
| `backend/src/cyclone/reconcile.py` | Modify | Rename `strategy="auto"``"pcn-exact"`. Add `_content_keys_match` pure helper. Add `_score_fallback_candidates` DB helper. Integrate fallback into `run()`. |
| `backend/tests/test_reconcile.py` | Modify | Update 3 existing assertions from `"auto"``"pcn-exact"`. Add 11 new tests for the helpers + integration. |
| `src/types/index.ts` | Modify | Widen the `Match.strategy` union from `"auto" \| "manual"` to `"pcn-exact" \| "score-auto" \| "manual"` (2 sites). |
---
## Task 1: Rename `strategy="auto"` → `strategy="pcn-exact"` everywhere
**Files:**
- Modify: `backend/src/cyclone/reconcile.py:87`
- Modify: `backend/tests/test_reconcile.py:58, 206, 309`
- Modify: `backend/tests/test_db_models.py:323, 338, 364`
- Modify: `src/types/index.ts:458, 510`
- [ ] **Step 1: Update the production code**
In `backend/src/cyclone/reconcile.py`, line 87, change:
```python
strategy="auto",
```
to:
```python
strategy="pcn-exact",
```
- [ ] **Step 2: Update the backend test assertions**
In `backend/tests/test_reconcile.py`:
- Line 58: change `assert matches[0].strategy == "auto"``assert matches[0].strategy == "pcn-exact"`
- Line 206: change `strategy="auto"``strategy="pcn-exact"`
- Line 309: change `strategy="auto"``strategy="pcn-exact"`
In `backend/tests/test_db_models.py`:
- Line 323: change `strategy="auto"``strategy="pcn-exact"`
- Line 338: change `assert loaded.strategy == "auto"``assert loaded.strategy == "pcn-exact"`
- Line 364: change `strategy="auto"``strategy="pcn-exact"`
- [ ] **Step 3: Widen the frontend TypeScript union**
In `src/types/index.ts`, line 458, change:
```typescript
strategy: "auto" | "manual";
```
to:
```typescript
strategy: "pcn-exact" | "score-auto" | "manual";
```
Line 510, change:
```typescript
match: { id: number; strategy: "auto" | "manual" };
```
to:
```typescript
match: { id: number; strategy: "pcn-exact" | "score-auto" | "manual" };
```
- [ ] **Step 4: Run the test suite to confirm nothing broke**
```bash
cd backend && .venv/bin/pytest tests/test_reconcile.py tests/test_db_models.py -v
```
Expected: all green. The `"auto"` string is gone from production code and tests.
- [ ] **Step 5: Commit**
```bash
git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile.py backend/tests/test_db_models.py src/types/index.ts
git commit -m "refactor(sp31): rename Match strategy 'auto' to 'pcn-exact' for audit clarity"
```
---
## Task 2: Add `_content_keys_match` pure helper (TDD)
**Files:**
- Modify: `backend/src/cyclone/reconcile.py` (append after `_pick_claim`)
- Modify: `backend/tests/test_reconcile.py` (append new test cases)
- [ ] **Step 1: Write the failing tests**
Add the following block at the end of `backend/tests/test_reconcile.py`:
```python
# --- SP31: content-keys fallback matcher (pure helper) -----------------------
def _make_remit_for_keys(remit_id: str, pcn: str, charge, npi: str):
"""Tiny shim with the three fields _content_keys_match reads."""
from cyclone.reconcile import _RemitLike # protocol not enforced at runtime
return type("R", (), {
"id": remit_id,
"payer_claim_control_number": pcn,
"total_charge_amount": charge,
"rendering_provider_npi": npi,
})()
def _make_claim_for_keys(claim_id: str, pcn: str, charge, npi: str):
return type("C", (), {
"id": claim_id,
"patient_control_number": pcn,
"total_charge": charge,
"rendering_provider_npi": npi,
})()
def test_content_keys_pcn_plus_charge_matches():
"""PCN + charge match (NPI mismatched) → True."""
from cyclone.reconcile import _content_keys_match
from decimal import Decimal
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "")
c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
assert _content_keys_match(r, c) is True
def test_content_keys_pcn_plus_npi_matches():
"""PCN + NPI match (charge mismatched) → True."""
from cyclone.reconcile import _content_keys_match
from decimal import Decimal
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
c = _make_claim_for_keys("c1", "ABC", Decimal("200.00"), "1111111111")
assert _content_keys_match(r, c) is True
def test_content_keys_charge_plus_npi_matches():
"""Charge + NPI match (PCN mismatched) → True."""
from cyclone.reconcile import _content_keys_match
from decimal import Decimal
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "1111111111")
assert _content_keys_match(r, c) is True
def test_content_keys_all_three_match():
"""All 3 match → True."""
from cyclone.reconcile import _content_keys_match
from decimal import Decimal
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
assert _content_keys_match(r, c) is True
def test_content_keys_only_pcn_does_not_match():
"""Only PCN matches (charge + NPI both wrong) → False."""
from cyclone.reconcile import _content_keys_match
from decimal import Decimal
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
c = _make_claim_for_keys("c1", "ABC", Decimal("200.00"), "2222222222")
assert _content_keys_match(r, c) is False
def test_content_keys_only_charge_does_not_match():
"""Only charge matches (PCN + NPI both wrong) → False."""
from cyclone.reconcile import _content_keys_match
from decimal import Decimal
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "2222222222")
assert _content_keys_match(r, c) is False
def test_content_keys_none_match():
"""All 3 fields disagree → False."""
from cyclone.reconcile import _content_keys_match
from decimal import Decimal
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111")
c = _make_claim_for_keys("c1", "XYZ", Decimal("200.00"), "2222222222")
assert _content_keys_match(r, c) is False
def test_content_keys_charge_tolerance_is_one_cent():
"""Charge differs by exactly $0.005 (rounding) → True ($0.01 tolerance)."""
from cyclone.reconcile import _content_keys_match
from decimal import Decimal
r = _make_remit_for_keys("r1", "ABC", Decimal("100.005"), "1111111111")
c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
assert _content_keys_match(r, c) is True
def test_content_keys_charge_differs_by_two_cents_no_match():
"""Charge differs by $0.02 → False (outside tolerance)."""
from cyclone.reconcile import _content_keys_match
from decimal import Decimal
r = _make_remit_for_keys("r1", "ABC", Decimal("100.02"), "1111111111")
c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
assert _content_keys_match(r, c) is False
def test_content_keys_empty_npi_does_not_count():
"""Empty NPI on remit counts as not-matched (falls back to PCN+charge rule)."""
from cyclone.reconcile import _content_keys_match
from decimal import Decimal
r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "")
c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111")
assert _content_keys_match(r, c) is True # PCN + charge still match
c2 = _make_claim_for_keys("c2", "XYZ", Decimal("100.00"), "1111111111")
assert _content_keys_match(r, c2) is False # only charge, no PCN
```
- [ ] **Step 2: Run the tests to verify they all FAIL with ImportError**
```bash
cd backend && .venv/bin/pytest tests/test_reconcile.py -k "content_keys" -v
```
Expected: 10 failures, all `ImportError: cannot import name '_content_keys_match'`.
- [ ] **Step 3: Implement `_content_keys_match` in `reconcile.py`**
In `backend/src/cyclone/reconcile.py`, add the following function after `_pick_claim` (around line 118):
```python
# --- SP31: content-keys fallback matcher -----------------------------------
CHARGE_TOLERANCE = Decimal("0.01")
KEYS_REQUIRED = 2
def _content_keys_match(remit, claim) -> bool:
"""Return True when at least KEYS_REQUIRED of {PCN, charge, NPI} agree.
Charge compared with ``CHARGE_TOLERANCE`` tolerance (rounding drift).
NPI counts as "not matched" when the remit's NPI is empty.
"""
keys_matched = 0
# PCN: payer_claim_control_number ↔ patient_control_number (stripped).
if (getattr(remit, "payer_claim_control_number", "") or "").strip() == \
(getattr(claim, "patient_control_number", "") or "").strip():
keys_matched += 1
# Charge: total_charge_amount ↔ total_charge (within $0.01).
remit_charge = getattr(remit, "total_charge_amount", None)
claim_charge = getattr(claim, "total_charge", None)
if remit_charge is not None and claim_charge is not None and \
abs(remit_charge - claim_charge) < CHARGE_TOLERANCE:
keys_matched += 1
# NPI: rendering_provider_npi ↔ rendering_provider_npi (must be non-empty).
remit_npi = (getattr(remit, "rendering_provider_npi", "") or "").strip()
claim_npi = (getattr(claim, "rendering_provider_npi", "") or "").strip()
if remit_npi and remit_npi == claim_npi:
keys_matched += 1
return keys_matched >= KEYS_REQUIRED
```
- [ ] **Step 4: Run the tests to verify they all PASS**
```bash
cd backend && .venv/bin/pytest tests/test_reconcile.py -k "content_keys" -v
```
Expected: 10 passed.
- [ ] **Step 5: Commit**
```bash
git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile.py
git commit -m "feat(sp31): add _content_keys_match pure helper (2-of-3 PCN/charge/NPI rule)"
```
---
## Task 3: Add `_score_fallback_candidates` DB helper (TDD)
**Files:**
- Modify: `backend/src/cyclone/reconcile.py` (append after `_content_keys_match`)
- Modify: `backend/tests/test_reconcile.py` (append integration tests)
- [ ] **Step 1: Write the failing integration tests**
Add to the end of `backend/tests/test_reconcile.py`:
```python
# --- SP31: content-keys fallback matcher (DB helper) ------------------------
def test_score_fallback_unique_match_returns_claim_id(db_session, make_claim, make_remit):
"""One claim in pool matches 2-of-3 → returns that claim."""
from decimal import Decimal
from datetime import date, timedelta
from cyclone.reconcile import _score_fallback_candidates
c1 = make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date_from=date(2026, 6, 1))
c2 = make_claim(patient_control_number="XYZ", total_charge=Decimal("999.99"),
rendering_provider_npi="2222222222",
service_date_from=date(2026, 6, 1))
r = make_remit(payer_claim_control_number="ABC",
total_charge_amount=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date=date(2026, 6, 5)) # ±30-day window
assert _score_fallback_candidates(db_session, r) == c1.id
def test_score_fallback_zero_matches_returns_none(db_session, make_claim, make_remit):
"""No claim in pool matches 2-of-3 → returns None."""
from decimal import Decimal
from datetime import date
from cyclone.reconcile import _score_fallback_candidates
make_claim(patient_control_number="XYZ", total_charge=Decimal("999.99"),
rendering_provider_npi="2222222222",
service_date_from=date(2026, 6, 1))
r = make_remit(payer_claim_control_number="ABC",
total_charge_amount=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date=date(2026, 6, 5))
assert _score_fallback_candidates(db_session, r) is None
def test_score_fallback_two_matches_returns_none(db_session, make_claim, make_remit):
"""Two claims in pool both match 2-of-3 → returns None (ambiguous)."""
from decimal import Decimal
from datetime import date
from cyclone.reconcile import _score_fallback_candidates
# Both claims have same PCN and same charge — both would match PCN+charge.
make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date_from=date(2026, 6, 1))
make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date_from=date(2026, 6, 2))
r = make_remit(payer_claim_control_number="ABC",
total_charge_amount=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date=date(2026, 6, 5))
assert _score_fallback_candidates(db_session, r) is None
def test_score_fallback_skips_already_matched(db_session, make_claim, make_remit):
"""Claim with matched_remittance_id set → excluded from pool."""
from decimal import Decimal
from datetime import date
from cyclone.reconcile import _score_fallback_candidates
c1 = make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date_from=date(2026, 6, 1),
matched_remittance_id="some-other-remit-id")
r = make_remit(payer_claim_control_number="ABC",
total_charge_amount=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date=date(2026, 6, 5))
assert _score_fallback_candidates(db_session, r) is None # only candidate was excluded
def test_score_fallback_skips_terminal_state(db_session, make_claim, make_remit):
"""Claim in PAID/DENIED/REJECTED/REVERSED/RECONCILED → excluded from pool."""
from decimal import Decimal
from datetime import date
from cyclone.db import ClaimState
from cyclone.reconcile import _score_fallback_candidates
make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date_from=date(2026, 6, 1),
state=ClaimState.PAID)
r = make_remit(payer_claim_control_number="ABC",
total_charge_amount=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date=date(2026, 6, 5))
assert _score_fallback_candidates(db_session, r) is None
def test_score_fallback_widened_window_catches_late_remit(db_session, make_claim, make_remit):
"""Remit 25 days after claim → still in pool (30-day window)."""
from decimal import Decimal
from datetime import date, timedelta
from cyclone.reconcile import _score_fallback_candidates
claim_date = date(2026, 6, 1)
remit_date = claim_date + timedelta(days=25)
c1 = make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date_from=claim_date)
r = make_remit(payer_claim_control_number="ABC",
total_charge_amount=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date=remit_date)
assert _score_fallback_candidates(db_session, r) == c1.id
def test_score_fallback_outside_window_excluded(db_session, make_claim, make_remit):
"""Remit 60 days after claim → excluded (outside 30-day window)."""
from decimal import Decimal
from datetime import date, timedelta
from cyclone.reconcile import _score_fallback_candidates
claim_date = date(2026, 6, 1)
remit_date = claim_date + timedelta(days=60)
make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date_from=claim_date)
r = make_remit(payer_claim_control_number="ABC",
total_charge_amount=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date=remit_date)
assert _score_fallback_candidates(db_session, r) is None
```
- [ ] **Step 2: Add the `make_claim` / `make_remit` fixtures to `conftest.py`**
Open `backend/tests/conftest.py` and add (or extend) the fixtures. If they already exist with these names, adjust the existing fixtures instead:
```python
import pytest
from decimal import Decimal
from datetime import date
from cyclone import db
from cyclone.db import Claim, Remittance, ClaimState
@pytest.fixture
def make_claim(db_session):
def _make(patient_control_number: str, total_charge: Decimal,
rendering_provider_npi: str, service_date_from: date,
matched_remittance_id: str | None = None,
state: ClaimState = ClaimState.SUBMITTED,
claim_id: str | None = None):
claim_id = claim_id or f"clm-{patient_control_number}-{service_date_from.isoformat()}"
c = Claim(
id=claim_id,
patient_control_number=patient_control_number,
total_charge=total_charge,
rendering_provider_npi=rendering_provider_npi,
service_date_from=service_date_from,
matched_remittance_id=matched_remittance_id,
state=state,
)
db_session.add(c)
db_session.flush()
return c
return _make
@pytest.fixture
def make_remit(db_session):
def _make(payer_claim_control_number: str, total_charge_amount: Decimal,
rendering_provider_npi: str, service_date: date,
remit_id: str | None = None):
remit_id = remit_id or f"remit-{payer_claim_control_number}-{service_date.isoformat()}"
r = Remittance(
id=remit_id,
payer_claim_control_number=payer_claim_control_number,
total_charge_amount=total_charge_amount,
rendering_provider_npi=rendering_provider_npi,
service_date=service_date,
)
db_session.add(r)
db_session.flush()
return r
return _make
```
If `Claim` / `Remittance` constructors don't accept these exact keyword args, check `backend/src/cyclone/db.py` for the actual field names and adjust. The plan assumes the ORM models expose these fields; the existing `reconcile.match()` already reads `matched_remittance_id`, `patient_control_number`, `service_date_from` from `Claim` so they definitely exist.
- [ ] **Step 3: Run the tests to verify they all FAIL with ImportError**
```bash
cd backend && .venv/bin/pytest tests/test_reconcile.py -k "score_fallback" -v
```
Expected: 7 failures, all `ImportError: cannot import name '_score_fallback_candidates'`.
- [ ] **Step 4: Implement `_score_fallback_candidates` in `reconcile.py`**
In `backend/src/cyclone/reconcile.py`, add the following function after `_content_keys_match`:
```python
# --- SP31: DB-side fallback candidate matcher -------------------------------
SCORE_FALLBACK_WINDOW_DAYS = 30
def _score_fallback_candidates(session, remit) -> Optional[str]:
"""Return a unique matching claim_id via the 2-of-3 content-keys rule.
Queries a ±SCORE_FALLBACK_WINDOW_DAYS candidate pool filtered by:
- claim.matched_remittance_id IS NULL (not yet linked)
- remit.claim_id IS NULL on the matched pair (enforced by caller;
this helper assumes the caller has already filtered on remit's
own claim_id)
- claim.state NOT IN terminal set (PAID, DENIED, REJECTED, REVERSED, RECONCILED)
- claim.service_date_from within window of remit.service_date
- claim.payer_id == remit.payer_id (same payer)
Returns the unique claim.id when exactly one candidate passes the
2-of-3 rule, otherwise None. Ambiguous matches (2+ candidates) also
return None — they land in the Inbox Unlinked lane for manual review.
"""
from sqlalchemy import select, and_, or_
from datetime import timedelta
from cyclone.db import Claim, Remittance, ClaimState
TERMINAL = {
ClaimState.PAID, ClaimState.DENIED, ClaimState.REJECTED,
ClaimState.REVERSED, ClaimState.RECONCILED,
}
if remit.service_date is None:
return None # need a date for the window query
window_lo = remit.service_date - timedelta(days=SCORE_FALLBACK_WINDOW_DAYS)
window_hi = remit.service_date + timedelta(days=SCORE_FALLBACK_WINDOW_DAYS)
candidates = list(session.execute(
select(Claim).where(
and_(
Claim.matched_remittance_id.is_(None),
Claim.service_date_from >= window_lo,
Claim.service_date_from <= window_hi,
Claim.state.notin_([s.value for s in TERMINAL]),
Claim.payer_id == remit.payer_id,
)
)
).scalars().all())
matched_ids = [c.id for c in candidates if _content_keys_match(remit, c)]
if len(matched_ids) == 1:
return matched_ids[0]
return None # 0 matches OR 2+ (ambiguous)
```
If `Claim.payer_id` or `Remittance.payer_id` aren't the actual column names, check `db.py` and adjust the filter. The plan assumes these exist (the SP30 widget already groups claims by payer).
- [ ] **Step 5: Run the tests to verify they all PASS**
```bash
cd backend && .venv/bin/pytest tests/test_reconcile.py -k "score_fallback" -v
```
Expected: 7 passed.
- [ ] **Step 6: Commit**
```bash
git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile.py backend/tests/conftest.py
git commit -m "feat(sp31): add _score_fallback_candidates DB helper with ±30-day candidate pool"
```
---
## Task 4: Integrate content-keys fallback into `reconcile.run()` (TDD)
**Files:**
- Modify: `backend/src/cyclone/reconcile.py` (`run` function)
- Modify: `backend/tests/test_reconcile.py` (append end-to-end tests)
- [ ] **Step 1: Write the failing end-to-end tests**
Add to the end of `backend/tests/test_reconcile.py`:
```python
# --- SP31: end-to-end integration via reconcile.run() -----------------------
def test_reconcile_run_emits_score_auto_match_when_pcn_misses(db_session, make_claim, make_remit):
"""A remit with no PCN match but matching charge+NPI triggers score-auto."""
from decimal import Decimal
from datetime import date
from cyclone import db as db_module
from cyclone.db import Batch, ClaimState
from cyclone.reconcile import run as reconcile_run
claim = make_claim(patient_control_number="UNIQUE-CLM",
total_charge=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date_from=date(2026, 6, 1))
remit = make_remit(payer_claim_control_number="DIFFERENT-PCN",
total_charge_amount=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date=date(2026, 6, 5))
batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5))
db_session.add(batch)
db_session.flush()
# Re-parent the remit to this batch (make_remit doesn't set batch_id).
remit.batch_id = batch.id
db_session.flush()
result = reconcile_run(db_session, batch.id)
assert result.matched == 1
# Verify Match row was written with strategy="score-auto"
from cyclone.db import Match
matches = list(db_session.execute(
db_module.select(Match).where(Match.remittance_id == remit.id)
).scalars().all())
assert len(matches) == 1
assert matches[0].strategy == "score-auto"
assert matches[0].claim_id == claim.id
# Verify ActivityEvent was emitted with kind="auto_matched_835"
from cyclone.db import ActivityEvent
events = list(db_session.execute(
db_module.select(ActivityEvent).where(
ActivityEvent.remittance_id == remit.id,
ActivityEvent.kind == "auto_matched_835",
)
).scalars().all())
assert len(events) == 1
assert events[0].claim_id == claim.id
# Verify claim state was flipped
db_session.refresh(claim)
assert claim.state in {ClaimState.PAID, ClaimState.PARTIAL, ClaimState.RECEIVED}
def test_reconcile_run_pcn_path_unchanged(db_session, make_claim, make_remit):
"""Existing PCN-exact match still produces strategy='pcn-exact' (regression guard)."""
from decimal import Decimal
from datetime import date
from cyclone import db as db_module
from cyclone.db import Batch, Match
from cyclone.reconcile import run as reconcile_run
claim = make_claim(patient_control_number="SAME-PCN",
total_charge=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date_from=date(2026, 6, 1))
remit = make_remit(payer_claim_control_number="SAME-PCN",
total_charge_amount=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date=date(2026, 6, 2))
batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5))
db_session.add(batch)
db_session.flush()
remit.batch_id = batch.id
db_session.flush()
reconcile_run(db_session, batch.id)
matches = list(db_session.execute(
db_module.select(Match).where(Match.remittance_id == remit.id)
).scalars().all())
assert len(matches) == 1
assert matches[0].strategy == "pcn-exact" # NOT "score-auto"
def test_reconcile_run_no_match_leaves_remit_unlinked(db_session, make_claim, make_remit):
"""No 2-of-3 match → no Match row, claim state unchanged."""
from decimal import Decimal
from datetime import date
from cyclone import db as db_module
from cyclone.db import Batch, Match, ClaimState
from cyclone.reconcile import run as reconcile_run
claim = make_claim(patient_control_number="UNRELATED",
total_charge=Decimal("999.99"),
rendering_provider_npi="2222222222",
service_date_from=date(2026, 6, 1))
remit = make_remit(payer_claim_control_number="OTHER",
total_charge_amount=Decimal("100.00"),
rendering_provider_npi="1111111111",
service_date=date(2026, 6, 5))
batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5))
db_session.add(batch)
db_session.flush()
remit.batch_id = batch.id
db_session.flush()
result = reconcile_run(db_session, batch.id)
assert result.matched == 0
matches = list(db_session.execute(
db_module.select(Match).where(Match.remittance_id == remit.id)
).scalars().all())
assert len(matches) == 0
db_session.refresh(claim)
assert claim.state == ClaimState.SUBMITTED # unchanged
db_session.refresh(remit)
assert remit.claim_id is None # unchanged
```
- [ ] **Step 2: Run the tests to verify they FAIL (run() not yet integrated)**
```bash
cd backend && .venv/bin/pytest tests/test_reconcile.py -k "reconcile_run_" -v
```
Expected: failures. The behavior should be wrong because `run()` doesn't yet call `_score_fallback_candidates`.
- [ ] **Step 3: Integrate the fallback into `reconcile.run()`**
In `backend/src/cyclone/reconcile.py`, modify the `run` function (around line 240-260) to add the fallback after the in-memory `match()` call. Replace the block:
```python
matches = match(unmatched_claims, new_remits)
```
with:
```python
matches = match(unmatched_claims, new_remits)
# SP31: content-keys fallback for remits that PCN-exact couldn't pair.
# Operates on the still-unmatched remits so we don't double-match.
matched_remit_ids = {m.remittance.id for m in matches}
used_claim_ids = {m.claim.id for m in matches}
for remit in new_remits:
if remit.id in matched_remit_ids:
continue
if remit.claim_id is not None:
continue # already linked
matched_claim_id = _score_fallback_candidates(session, remit)
if matched_claim_id is None:
continue
# Find the claim object in the unmatched_claims list (it was loaded).
target_claim = next(
(c for c in unmatched_claims if c.id == matched_claim_id),
None,
)
if target_claim is None or target_claim.id in used_claim_ids:
continue
matches.append(Match(
claim=target_claim, remittance=remit,
strategy="score-auto", is_reversal=remit.is_reversal,
))
used_claim_ids.add(target_claim.id)
```
Then, in the existing apply loop further down (around line 270), change the ActivityEvent emission so score-auto matches emit `kind="auto_matched_835"` instead of the default `"reconcile"`. Find:
```python
session.add(ActivityEvent(
ts=datetime.now(timezone.utc), kind=intent.activity_kind,
batch_id=batch_id, claim_id=m.claim.id,
remittance_id=m.remittance.id,
payload_json={"new_state": m.claim.state.value},
))
```
and replace the `kind=intent.activity_kind` with:
```python
kind=("auto_matched_835" if m.strategy == "score-auto" else intent.activity_kind),
```
And update the payload to include the score-auto metadata for the operator. Change the `payload_json=` line to:
```python
payload_json=(
{"new_state": m.claim.state.value, "strategy": m.strategy}
if m.strategy == "score-auto"
else {"new_state": m.claim.state.value}
),
```
- [ ] **Step 4: Run the new end-to-end tests to verify they PASS**
```bash
cd backend && .venv/bin/pytest tests/test_reconcile.py -k "reconcile_run_" -v
```
Expected: 3 passed.
- [ ] **Step 5: Run the full reconcile test suite to verify no regression**
```bash
cd backend && .venv/bin/pytest tests/test_reconcile.py tests/test_db_models.py -v
```
Expected: all green (existing tests + 20 new tests).
- [ ] **Step 6: Commit**
```bash
git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile.py
git commit -m "feat(sp31): integrate content-keys fallback into reconcile.run() with auto_matched_835 event"
```
---
## Task 5: Verify end-to-end (full test suite + manual smoke)
**Files:**
- No new files. Verification only.
- [ ] **Step 1: Run the full backend test suite**
```bash
cd backend && .venv/bin/pytest -q
```
Expected: 0 failures. If any test fails, fix it before proceeding.
- [ ] **Step 2: Run the frontend typecheck**
```bash
npm run typecheck
```
Expected: 0 errors. The widened `strategy` union in `src/types/index.ts` should compile cleanly.
- [ ] **Step 3: Run the frontend lint**
```bash
npm run lint
```
Expected: 0 errors.
- [ ] **Step 4: Manual smoke test via the running prod stack**
The 837+835 fixtures already exist (`backend/tests/fixtures/co_medicaid_837p.txt` and `backend/tests/fixtures/co_medicaid_835.txt`). Confirm by reading the envelopes:
```bash
grep -E "^ISA|^GS|^ST|^BPR|^TRN|^CLP" backend/tests/fixtures/co_medicaid_835.txt | head -10
```
Then send a parse-835 request via the existing `/api/parse-835` endpoint or the CLI:
```bash
python -m cyclone.cli parse-835 backend/tests/fixtures/co_medicaid_835.txt --output-dir /tmp/sp31-smoke
```
Expected: a Remittance row is written + a Match row is created. Check via SQL:
```bash
docker compose exec -T backend python3 <<'PY'
from cyclone import db
db.init_db()
from sqlalchemy import select
with db.SessionLocal()() as s:
matches = list(s.execute(
select(db.Match).order_by(db.Match.matched_at.desc()).limit(5)
).scalars().all())
for m in matches:
print(f" {m.strategy:12} claim={m.claim_id[:24]:24} remit={m.remittance_id[:24]:24} reversal={m.is_reversal}")
PY
```
Expected: at least one row with `strategy=pcn-exact` (the co_medicaid_835 fixture has PCN matching the co_medicaid_837p fixture) or `strategy=score-auto` (if you intentionally mangle the PCN in a copy of the fixture to trigger fallback).
- [ ] **Step 5: Commit any incidental cleanup**
If step 1-4 surfaced any unrelated failures, fix them on this branch. Otherwise no commit.
```bash
git status
```
If clean, proceed to merge.
---
## Self-Review
**1. Spec coverage:**
- §1 D1 (strict 2-of-3 rule) → Task 2 ✅
- §1 D2 (PCN-first, fallback) → Task 4 ✅
- §1 D3 (Match row strategy label + payload) → Task 4 ✅
- §1 (idempotency guards) → Task 3 ✅
- §1 (reversal handling unchanged) → Task 4 (no changes to apply_reversal) ✅
- §2 D3 (Match.strategy labels) → Task 1 (rename) + Task 4 (new label) ✅
- §2 D4 ($0.01 tolerance) → Task 2 ✅
- §2 D5 (±30-day window) → Task 3 ✅
- §2 D6 (3 idempotency guards) → Task 3 (3 tests) ✅
- §2 D7 (no new UI) → no tasks (intentional) ✅
- §2 D8 (`auto_matched_835` ActivityEvent) → Task 4 ✅
- §2 D9 (no auth changes) → no tasks (intentional) ✅
- §3 edge cases → covered across Tasks 2, 3, 4 ✅
- §4 testing approach → 20 new tests across 4 test files ✅
- §5 out-of-scope reminders → no backfill, no 999 changes, etc. (none touched) ✅
**2. Placeholder scan:** No "TBD", "TODO", "implement later". The conftest fixture instructions include "If `Claim.payer_id` doesn't exist, check `db.py`" — this is intentional contingency, not a placeholder; the actual implementation step checks the file.
**3. Type consistency:** All references to `_content_keys_match`, `_score_fallback_candidates`, `Match(strategy="score-auto")`, `kind="auto_matched_835"`, `CHARGE_TOLERANCE`, `KEYS_REQUIRED`, `SCORE_FALLBACK_WINDOW_DAYS`, and the `make_claim` / `make_remit` fixture signatures are consistent across all tasks. The `RemitLike` / `ClaimLike` Protocol usage in Task 2 matches the existing patterns in `reconcile.py`.
@@ -0,0 +1,130 @@
# Sub-project 31 — 835 Strict Content-Match Auto-Link: Design Spec
**Date:** 2026-07-02
**Status:** Draft, awaiting user sign-off
**Branch:** `sp31-835-strict-content-match`
**Aesthetic direction:** No UI changes. State flips driven by backend logic surface immediately via the SP30 Recent Batches widget.
---
## 1. Scope
Today when an 835 (ERA remittance advice) arrives, the existing matcher in `cyclone.reconcile.run` only auto-links a remit to a claim when **`payer_claim_control_number == claim.patient_control_number` AND service dates are within ±7 days**. That single-key match misses most real-world remits: Gainwell's 835 carries its own independent control numbers (M-prefix filenames, ISA13 assigned by Gainwell, no echo of the 837's ST02), so the only reliable join lives in the 835's **content fields** — CLP01 (PCN), CLP03 (total charge), CLP07 (rendering provider NPI). The operator can see in the Recent Batches widget that a batch has unresolved claims, but until a manual match is performed (admin-only endpoint `POST /api/reconciliation/match`) those remits sit in the Inbox Unlinked lane with no `Claim.state` flip to PAID / DENIED / PARTIAL.
SP31 closes that gap by adding a **deterministic content-key fallback matcher** to the 835 ingest path. When the existing PCN-exact path misses, a strict **"any 2 of {PCN, charge, NPI}" rule** runs against a wider candidate pool (±30 days) and auto-links on a hit. The result: more remits get linked at parse time, more claims get state-flipped to a terminal/settled state, and the Recent Batches widget reflects the truth immediately (no manual intervention needed).
**In scope:**
- One new helper `_content_keys_match(remittance, claim)` in `cyclone.reconcile` that returns True when at least 2 of {CLP01↔CLM01 PCN, CLP03↔CLM02 charge, CLP07↔NM1*82 NPI} agree exactly (charge compared with a $0.01 tolerance).
- One new helper `_score_fallback_candidates(session, remittance)` that queries the candidate pool (same payer_id, billed_within ±30 days, claim.state ∈ {SUBMITTED, RECEIVED, PARTIAL}, no existing match) and returns the unique match when one exists, or None when zero or multiple claims pass.
- One new branch inside `cyclone.reconcile.run` that invokes the content-keys fallback **after** the existing PCN-exact path misses. On a hit, calls `apply_payment` / `apply_reversal`, writes a `Match(strategy="score-auto")` row with `score=100` and a payload describing which keys matched, and emits an `auto_matched_835` `ActivityEvent`.
- Idempotency guards: skip scoring when `claim.matched_remittance_id` is set, when `remittance.claim_id` is set, or when `claim.state` is already terminal (PAID / DENIED / REJECTED / REVERSED / RECONCILED). Re-ingesting the same 835 is a no-op for already-linked pairs.
- Reversal handling unchanged: status_code 21/22 always goes through `apply_reversal`, creating a 2nd `Match` row per existing SP27 convention. The strategy label (`pcn-exact` vs `score-auto`) reflects which path made the link.
**Out of scope:**
- No schema migration. `Match` and `ActivityEvent` already exist; the new strategy label `"score-auto"` is a string value within the existing column.
- No backfill. The 672 currently-unlinked claims in production stay unlinked; SP31 only affects new 835 ingests. An operator can still run the existing manual-match endpoints to backfill at their own pace.
- No changes to the existing PCN-exact matcher. It stays as the primary (fast) path. SP31 only adds a fallback branch.
- No changes to the Inbox Unlinked lane UI, the Recent Batches widget, or the ClaimDrawer / RemitDrawer. State flips already surface through the existing live-tail path (SP25) and the SP30 widget.
- No new endpoints. The existing `POST /api/reconciliation/match` and `POST /api/reconciliation/unmatch` remain the manual-fallback surfaces.
- No 999 / 277CA / TA1 changes. The ack↔claim auto-linker from SP28 stays untouched (those use the 837 filename / ST02 echo, not content keys).
---
## 2. Decisions (locked during brainstorming)
### D1. Match rule is strict "any 2 of {PCN, charge, NPI} match exactly" — no weighted scoring, no thresholds, no ambiguity tiers
The user's instinct that "we might be over-building this" led to dropping the original 4-field weighted score with a 90-point threshold. Instead, SP31 uses a **deterministic three-key rule**:
- **PCN match:** `remittance.payer_claim_control_number == claim.patient_control_number` (after `.strip()` on both).
- **Charge match:** `|remittance.total_charge_amount claim.total_charge| < $0.01`.
- **NPI match:** `remittance.rendering_provider_npi == claim.rendering_provider_npi` AND the remit's NPI is non-empty.
Auto-link when `keys_matched ≥ 2`. **No weighted score, no 90/75 threshold, no "runner-up within 10 points = ambiguous" rule.** The decision is reproducible by inspecting the three fields; "score was 91" is no longer in the operator's vocabulary. The existing scoring module (`cyclone.scoring.score_pair`) is preserved for the Inbox display lane but is no longer invoked from the auto-match path.
### D2. Content-key fallback runs *after* the existing PCN-exact path misses — not as a replacement
`reconcile.run` already PCN-matches today. SP31 leaves that path untouched and inserts the content-keys fallback as a second branch. Rationale: when PCN matches today, the match is rock-solid (no charge comparison, no NPI check needed). When PCN misses, content-keys is the next-best signal. Stacking keeps the fast path fast and the audit trail obvious (Match.strategy reveals which branch fired).
### D3. The Match row gets `strategy="score-auto"` and a payload describing which keys matched
This differentiates from the existing `strategy="pcn-exact"` (renamed from today's `strategy="auto"`). The Match row payload stores `{"keys_matched": ["pcn", "charge"], "candidate_count": 7}` so the operator can see exactly why a link was made. The Inbox Unlinked lane will surface these labels too (existing display layer reads `Match.strategy`). The existing `score` column is set to `100` for content-keys matches (placeholder for the legacy display layer; not used by the new logic).
### D4. Charge tolerance is $0.01, not exact-zero
CO Medicaid and most payers carry charge to 2 decimal places; rounding can introduce ±$0.005 of drift. $0.01 is the conservative tolerance that still catches real mismatches (a $50 charge vs a $100 charge differs by $50, not $0.01).
### D5. Candidate pool is widened to ±30 days (was ±7 for PCN)
Strict keys (≥ 2 of 3) are a much stronger signal than PCN-alone, so the wider window is safe. The 30-day window captures remits that arrive late (e.g., week-long payer processing delays plus weekends plus holidays) without pulling in unrelated claims from prior months.
### D6. Idempotency is enforced by three guards, not by deduplication
Before scoring a candidate, the matcher checks:
1. `claim.matched_remittance_id IS NULL` (claim hasn't been linked yet)
2. `remittance.claim_id IS NULL` (remit hasn't been linked yet)
3. `claim.state NOT IN {PAID, DENIED, REJECTED, REVERSED, RECONCILED}` (terminal-state guard)
If any guard fails, the candidate is excluded from the pool. If no candidate remains after filtering, no match is made and the remit lands in the Inbox Unlinked lane (existing behavior).
### D7. No operator-facing UI for content-keys matches — the existing live-tail + Recent Batches widget surface them
The `Match` row (D3) and the `auto_matched_835` ActivityEvent (D8) are the operator-visible artifacts. The Recent Batches widget from SP30 already reflects `Claim.state` changes within one re-fetch cycle (TanStack Query interval). No new "auto-matched by score" badge anywhere — keeps the UI surface minimal and lets the widget do its job.
### D8. An `auto_matched_835` ActivityEvent is emitted on every successful content-keys match
Payload: `{claim_id, remittance_id, strategy: "score-auto", keys_matched: [...], candidate_count: N}`. Visible in the Activity page alongside existing `reconcile` events. Lets the operator answer "why did this 835 flip a claim?" without inspecting the Match table.
### D9. Reversals (status_code 21/22) keep the existing 2-row Match audit trail
When a reversal 835 arrives and matches via either path, `apply_reversal` flips the claim state (PAID/PARTIAL → REVERSED) and writes a **second** `Match` row (per SP27 convention). The strategy label on the new row reflects which branch matched the reversal. This preserves the audit trail: the first Match row says "paid", the second says "reversed".
### D10. No changes to auth posture, no new endpoints, no CLI changes
Every existing endpoint's auth boundary is preserved. The 835 ingest path runs server-side; no operator action is required to trigger a content-keys match. The existing manual-match endpoints (`POST /api/reconciliation/match`, `POST /api/reconciliation/unmatch`) remain the escape hatch for edge cases.
---
## 3. Edge cases & how they're handled
| Scenario | Behavior |
|---|---|
| PCN matches (existing path) | Unchanged. `apply_payment` / `apply_reversal`, `Match(strategy="pcn-exact")`. |
| PCN misses, content-keys 2-of-3 matches, exactly 1 candidate | `apply_payment`, `Match(strategy="score-auto", score=100)`, Activity event. |
| PCN misses, content-keys 2-of-3 matches, 2+ candidates | No match. Remit lands in Inbox Unlinked. (Deterministic rule — duplicates would be ambiguous.) |
| PCN misses, content-keys 0-of-3 or 1-of-3 | No match. Inbox Unlinked. |
| Reversal (status_code 21/22) | Always `apply_reversal`. 2nd Match row. Strategy label per which path matched. |
| `claim.matched_remittance_id` set | Skip scoring entirely. Idempotent. |
| `remittance.claim_id` set | Skip scoring entirely. Idempotent. |
| `claim.state` in terminal set | Skip scoring. State already settled. |
| Remit has empty CLP07 (no rendering NPI) | NPI key counted as "not matched" — falls back to PCN + charge rule. |
| Charge differs by exactly $0.01 | Counts as matched (D4 tolerance). |
| Same PCN across two claims in the pool (legitimate re-submit) | Both candidates count toward the pool; if only one also matches charge or NPI, that one wins. |
---
## 4. Testing approach
14 new tests in `backend/tests/test_reconcile.py`, modeled on the existing `reconcile` test patterns. Coverage targets:
- **Happy paths** (4): PCN+charge match, PCN+NPI match, charge+NPI match (no PCN), all 3 match.
- **Negative paths** (3): only-PCN-match doesn't auto-link, only-charge-match doesn't auto-link, none-match doesn't auto-link.
- **Idempotency** (3): claim already matched skips, remit already linked skips, terminal claim state skips.
- **State transitions** (2): reversal uses `apply_reversal`, paid remittance flips claim to PAID.
- **Audit trail** (2): Match row strategy label is `score-auto`, Activity event `auto_matched_835` emitted with correct payload.
- **Regression guard** (1): PCN-exact path unchanged — when PCN matches, behavior is identical to today.
No frontend tests (no UI changes). Manual smoke via Playwright: ingest an 835 fixture against a synthetic 837 batch and assert the claim state flips in the Recent Batches widget within one re-fetch.
---
## 5. Out-of-scope reminders (explicit)
- **Backfilling the 672 unmatched claims already in production.** Operator-driven, not auto.
- **Changing the existing 999 / 277CA / TA1 auto-linker.** Those use the 837 filename / ST02 echo (SP28), a different signal than content keys.
- **Changing the Inbox Unlinked lane UI.** The lane already exists; SP31 only changes which remits end up in it (fewer).
- **Tuning charge tolerance.** $0.01 is the conservative default; revisit only if real production data shows false-negatives.
- **Weighted scoring from `cyclone.scoring` is preserved for display only.** The auto-match path does not invoke `score_pair`.
+2 -2
View File
@@ -455,7 +455,7 @@ export interface Match {
id: number; id: number;
claim_id: string; claim_id: string;
remittance_id: string; remittance_id: string;
strategy: "auto" | "manual"; strategy: "pcn-exact" | "score-auto" | "manual";
matched_at: string; matched_at: string;
prior_claim_state?: ClaimState | null; prior_claim_state?: ClaimState | null;
is_reversal: boolean; is_reversal: boolean;
@@ -507,7 +507,7 @@ export interface UnmatchedResponse {
export interface MatchResponse { export interface MatchResponse {
claim: UnmatchedClaim; claim: UnmatchedClaim;
match: { id: number; strategy: "auto" | "manual" }; match: { id: number; strategy: "pcn-exact" | "score-auto" | "manual" };
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------