Resolving NDC Gaps with Fuzzy GPI Fallback
When an inbound 407-D7 Product/Service ID has no exact row in the crosswalk snapshot, the engine faces a choice that is easy to get catastrophically wrong: guess a therapeutic class from a near-neighbor NDC, or reject the claim. A wrong guess is worse than a reject — assigning the wrong 14-digit GPI silently drops the claim into the wrong formulary tier, the wrong clinical edit group, and the wrong rebate contract, and it does so quietly, without a reject code an auditor could trace. This page settles one decision: how to build a bounded, deterministic fuzzy fallback that resolves a missing NDC by rolling up its package or product family only when the therapeutic class is unambiguous, and rejects with 70 Product/Service Not Covered or routes to manual review with 75 the moment it is not. It extends the tiered resolver in NDC-to-GPI Crosswalk Automation past its exact-match tiers into the genuinely-ambiguous cases, where the safe answer is often to refuse rather than to approximate.
The Fallback Decision: Tiers Versus Risk
“Fuzzy” here never means string-similarity guessing. It means widening the match key one bounded step at a time — from the full 11-digit NDC to the 9-digit labeler-product prefix to the product family — and, at each step, only adopting a GPI when every candidate in that neighborhood agrees on the therapeutic class. The load-bearing invariant is the first 10 GPI digits: they encode therapeutic class through strength and form, and they drive the prior-authorization key. If a neighborhood spans more than one GPI-10 value, no fuzzy match is safe, and the claim rejects rather than resolving to a coin-flip class.
| Tier | Strategy | Match key | Confidence | Accept condition | On failure |
|---|---|---|---|---|---|
| F1 | Package rollup | 9-digit labeler-product prefix | 0.95 | family maps to a single 14-digit GPI | fall to F2 |
| F2 | Therapeutic-class rollup | 9-digit prefix, GPI-10 vote | 0.80 | all family GPIs share the same GPI-10 | reject 70 |
| — | Labeler-only match | 5-digit labeler | 0.30 | never accepted — too broad | reject 70 |
| — | No product family | — | — | — | route 75 (manual review) |
Two rules keep the fallback from becoming a guess. First, confidence is gated: a candidate is only adopted when its tier confidence clears a configured floor, so a plan can forbid the low-confidence class rollup for specialty drugs where package precision matters. Second, a labeler-only match is never accepted — a single labeler (the 5-digit segment) manufactures dozens of unrelated drugs, so its neighborhood spans many therapeutic classes by construction. When the fallback cannot resolve safely, it defers to the exact-match reject semantics documented in Handling Reject Code 70: Product/Service Not Covered, and the underlying normalization and ingestion of the neighborhood index is the memory-efficient build covered in How to Map Legacy NDC Codes to GPI Standards in Python.
Figure: The fallback widens the match key one bounded step at a time and adopts a GPI only when the therapeutic class (GPI-10) is unanimous — a family that spans multiple classes rejects with 70 rather than guessing.
Step-by-Step Implementation
The resolver below runs only after the exact-match tiers have missed. It reads a pre-built product-family index — a dict from 9-digit prefix to the set of GPIs seen for that family in the snapshot — and applies confidence-gated tiers, auditing every degraded resolution. It never accepts or logs raw claim bytes; it operates on the normalized 407-D7 NDC and a tokenized 402-D2 reference only.
1. Assume the exact match already failed and normalize the neighborhood key. The 9-digit labeler-product prefix is the tightest safe generalization of a package-specific NDC.
2. Read the family index and count distinct GPIs. A single GPI is an unambiguous package rollup (F1); multiple GPIs demand a therapeutic-class vote.
3. Vote on the GPI-10 therapeutic class. If every family member shares the first 10 GPI digits, adopt that class (F2) with a synthetic package suffix and a degraded flag; if not, the class is ambiguous and the claim rejects 70.
4. Gate on confidence and audit. Only adopt when the tier’s confidence clears the configured floor, and emit one audit event per degraded resolution so downstream specialty routing knows the package precision was lost.
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum
from typing import Optional
logger = logging.getLogger("pbm.fuzzy_gpi")
REJ_NOT_COVERED = "70" # Product/Service Not Covered — ambiguous / unsafe fuzzy match
REJ_MANUAL = "75" # route to manual review — no product family at all
class FuzzyTier(str, Enum):
F1_PACKAGE_ROLLUP = "F1_PACKAGE_ROLLUP"
F2_CLASS_ROLLUP = "F2_CLASS_ROLLUP"
@dataclass(frozen=True)
class FuzzyResult:
gpi: Optional[str]
status: str # degraded | ambiguous | unmapped
reject_code: Optional[str]
tier: Optional[FuzzyTier]
confidence: Decimal
_TIER_CONFIDENCE = {
FuzzyTier.F1_PACKAGE_ROLLUP: Decimal("0.95"),
FuzzyTier.F2_CLASS_ROLLUP: Decimal("0.80"),
}
class FuzzyGPIResolver:
"""Bounded, deterministic fallback for NDCs with no exact crosswalk row."""
def __init__(self, family_index: dict[str, set[str]],
min_confidence: Decimal = Decimal("0.75")):
# family_index: 9-digit labeler-product prefix -> set of 14-digit GPIs.
self._family = family_index
self._min_confidence = min_confidence
def resolve_gap(self, ndc_407d7: str, claim_ref: str) -> FuzzyResult:
prefix9 = ndc_407d7[:9] # labeler(5) + product(4)
family = self._family.get(prefix9)
if not family:
# No product family in the snapshot — cannot generalize safely.
return self._audit(claim_ref, FuzzyResult(
gpi=None, status="unmapped", reject_code=REJ_MANUAL,
tier=None, confidence=Decimal("0")))
if len(family) == 1:
# F1: every package in the family shares one GPI — safe unit-of-use rollup.
gpi = next(iter(family))
return self._gate(claim_ref, gpi, FuzzyTier.F1_PACKAGE_ROLLUP, "degraded")
# Multiple GPIs: vote on the therapeutic class (first 10 GPI digits).
gpi10s = {g[:10] for g in family}
if len(gpi10s) == 1:
# F2: same class, differing only in mfr/package digits — adopt GPI-10.
class_gpi = next(iter(gpi10s)) + "0000" # synthetic package suffix, flagged
return self._gate(claim_ref, class_gpi, FuzzyTier.F2_CLASS_ROLLUP, "degraded")
# Family spans multiple therapeutic classes — never guess.
return self._audit(claim_ref, FuzzyResult(
gpi=None, status="ambiguous", reject_code=REJ_NOT_COVERED,
tier=None, confidence=Decimal("0")))
def _gate(self, claim_ref: str, gpi: str, tier: FuzzyTier, status: str) -> FuzzyResult:
confidence = _TIER_CONFIDENCE[tier]
if confidence < self._min_confidence:
# Tier is below the plan's floor (e.g. specialty needs package precision).
return self._audit(claim_ref, FuzzyResult(
gpi=None, status="ambiguous", reject_code=REJ_NOT_COVERED,
tier=tier, confidence=confidence))
return self._audit(claim_ref, FuzzyResult(
gpi=gpi, status=status, reject_code=None, tier=tier, confidence=confidence))
def _audit(self, claim_ref: str, result: FuzzyResult) -> FuzzyResult:
# Audit every degraded/rejected resolution — tokenized ref only, no PHI.
logger.info(json.dumps({
"event": "fuzzy_gpi_fallback",
"claim_ref": claim_ref, # tokenized 402-D2, non-PHI
"gpi": result.gpi,
"status": result.status,
"reject_code": result.reject_code,
"tier": result.tier.value if result.tier else None,
"confidence": str(result.confidence),
}))
return resultThe resolver is a pure function of the normalized NDC and the immutable family index, so a fuzzy resolution replays identically under a payer audit — the same gap always resolves to the same tier, confidence, and reject code. Every non-exact outcome is stamped degraded or rejected and audited, which is what lets specialty and unit-of-measure routing downstream decide whether the lost package precision matters, exactly as the parent crosswalk’s degraded flag governs.
Verification and Testing Pattern
The properties to pin: a single-GPI family rolls up safely, a same-class family adopts the shared GPI-10, a multi-class family rejects 70 rather than guessing, and the confidence floor can veto a low-tier match.
import pytest
from decimal import Decimal
def resolver(index, floor="0.75"):
return FuzzyGPIResolver(index, min_confidence=Decimal(floor))
def test_single_gpi_family_is_f1_rollup():
idx = {"001234567": {"27100010100320"}}
res = resolver(idx).resolve_gap("00123456799", "REF-1")
assert res.tier == FuzzyTier.F1_PACKAGE_ROLLUP
assert res.gpi == "27100010100320" and res.reject_code is None
def test_same_class_family_adopts_gpi10():
# Two packages, same first-10 GPI, different mfr/package digits.
idx = {"001234567": {"27100010100320", "27100010109999"}}
res = resolver(idx).resolve_gap("00123456701", "REF-2")
assert res.tier == FuzzyTier.F2_CLASS_ROLLUP
assert res.gpi == "2710001010" + "0000" # GPI-10 + synthetic package
assert res.status == "degraded"
def test_multi_class_family_rejects_70():
# Family spans two therapeutic classes -> unsafe, reject rather than guess.
idx = {"001234567": {"27100010100320", "36990020100310"}}
res = resolver(idx).resolve_gap("00123456701", "REF-3")
assert res.reject_code == "70" and res.gpi is None
assert res.status == "ambiguous"
def test_no_family_routes_to_manual_75():
res = resolver({}).resolve_gap("00999888777", "REF-4")
assert res.reject_code == "75" and res.status == "unmapped"
def test_confidence_floor_vetoes_class_rollup():
idx = {"001234567": {"27100010100320", "27100010109999"}}
# Specialty plan requires >= 0.90 confidence; F2 at 0.80 is vetoed to 70.
res = resolver(idx, floor="0.90").resolve_gap("00123456701", "REF-5")
assert res.reject_code == "70"The multi-class test is the load-bearing one: it proves the fallback refuses to assign a therapeutic class when the neighborhood is genuinely ambiguous — the difference between a traceable 70 reject and a silent misassignment that mistiers a member and corrupts rebate attribution.
Gotchas and PHI Guardrails
- Labeler-only matching. Widening the key to the 5-digit labeler is never safe: one labeler makes many unrelated drugs, so the neighborhood spans many classes and the GPI-10 vote can never be unanimous. Stop at the 9-digit prefix; below that, reject.
- Trusting digits 11–14. The synthetic
0000package suffix on an F2 rollup is a placeholder, not real package data. Anything downstream that needs true package precision — specialty dispensing, unit-of-measure conversion — must read thedegradedflag and refuse to treat the suffix as authoritative. - Snapshot skew in the family index. If two workers hold family indexes built from different crosswalk snapshots, the same gap resolves to different tiers. Stamp every fuzzy result with the snapshot version and reconcile failover on it, the same parity discipline the NDC-to-GPI Crosswalk Automation resolver enforces.
- Leading-zero destruction. A fuzzy prefix computed from an NDC that was cast to
intupstream slices the wrong 9 digits and gathers the wrong family. NDCs are strings end to end; normalize to unhyphenated 11-digit form before taking any prefix. - Deprecated NDC masquerading as a gap. A retired NDC with a real successor should resolve through the deprecated-map tier, not the fuzzy fallback — reaching the fallback for a drug that has a known successor means the deprecation feed is stale. Ingest retirement notices from the FDA National Drug Code Directory so real successors never fall into fuzzy territory.
- PHI in the fallback audit. A degraded resolution is exactly the record a compliance reviewer inspects, so it must be PHI-safe by construction. The audit event carries the tokenized
402-D2reference, the candidate GPI, the tier, and the confidence — never the302-C2Cardholder ID, the310-CAPatient Name, or the raw transaction. Log the resolution shape, not the claim.
Related
- NDC-to-GPI Crosswalk Automation — the parent resolver whose exact-match tiers this fallback extends into ambiguous cases.
- How to Map Legacy NDC Codes to GPI Standards in Python — memory-efficient ingestion and normalization that builds the product-family index this page reads.
- Handling Reject Code 70: Product/Service Not Covered — the reject semantics this fallback defers to when no safe class can be assigned.
- Fallback Routing Logic Design — the failover and identifier-repair layer a warm family index depends on.
- Security & Compliance Boundaries for Claims Data — the audit and PHI-handling obligations every degraded resolution event must satisfy.
← Back to NDC-to-GPI Crosswalk Automation