← Methods repository
concept

RxNorm Drug Terminology

The National Library of Medicine's normalized naming system for clinical drugs in the US, assigning stable numeric concept identifiers (RxCUIs) to drug ingredients, dose forms, strengths, and branded products so that thousands of package-level NDC codes from any pharmacy or EHR system can all be linked to a single shared ingredient concept.

Data_Standardcoding-systemdata-standardprimitivedrugsterminologyrxnormrxcuirxnav
Methods reference only. Use primary source citations and local policy before applying this in a study protocol, regulatory submission, payer dossier, or clinical decision.

In plain language

RxNorm is a public database maintained by the US National Library of Medicine that gives every drug — by its active ingredient, strength, pill form, and brand name — a stable number called an RxCUI. Think of it as a universal translator for prescription drug names: a patient could fill the same atorvastatin tablet from a dozen different generic manufacturers, each with a different barcode-style drug code (called an NDC), but all of those codes map to the same single RxCUI for "atorvastatin 20 mg oral tablet." For researchers studying which patients took which drugs, RxNorm is what allows thousands of package-level drug codes in insurance claims to be collapsed into one manageable list — one ingredient, one search.

RxNorm

is a normalized drug vocabulary produced and maintained by the National Library of Medicine (NLM). Its defining purpose is to give every clinically meaningful drug concept — ingredient, strength, dose form, brand, and packaged product — a single stable identifier (the RxCUI, RxNorm Concept Unique Identifier) so that systems built on different source vocabularies can exchange drug information without losing meaning. Source vocabularies that RxNorm links include First Databank (FDB), Multum, Micromedex, Gold Standard, the VA National Drug File (VANDF), MedlinePlus, and others. NLM releases a full monthly build plus weekly incremental updates. The core RxNorm files are freely distributable without a UMLS license; however, some source-vocabulary string content embedded in the full RxNorm distribution files does require a UMLS Metathesaurus license from NLM to reproduce. Analysts using only the RxCUI identifiers, relationship tables, and the public RxNav API (which returns standard RxNorm atoms) do not require a UMLS license.

Core machinery: the RxNorm concept hierarchy

RxNorm organizes drug concepts into a lattice of term types (TTY) connected by explicit relationships:

  • IN (Ingredient): The active moiety — e.g., atorvastatin. This is the rollup target for most RWE
  • PIN (Precise Ingredient): A salt or ester form distinguished from the free base — e.g.,
  • MIN (Multiple Ingredients): A multi-ingredient combination, e.g., amlodipine / atorvastatin.
  • DF (Dose Form): The form alone — tablet, capsule, oral solution.
  • SCDC (Semantic Clinical Drug Component): Ingredient + strength — e.g., atorvastatin 10 mg.
  • SCD (Semantic Clinical Drug): Ingredient + strength + dose form (generic) — e.g., *atorvastatin
  • SBD (Semantic Branded Drug): Like SCD but branded — e.g., Lipitor 20 mg oral tablet.
  • BN (Brand Name): The trade name alone — Lipitor.
  • GPCK / BPCK (Generic Pack / Branded Pack): Multi-component packaged products.

Relationships traverse the hierarchy. `has_ingredient` links SCD → IN. `consists_of` links SCD → SCDC. `tradename_of` links SBD → SCD. `has_dose_form` links SCD → DF. The `CONCEPT_ANCESTOR` table in OMOP CDM materializes all transitive ancestor–descendant paths so a single SQL join can find every SCD or SBD whose ancestor is the atorvastatin IN.

The workhorse RWE operation: NDC → RxCUI → ingredient rollup

A US pharmacy claim carries an NDC (National Drug Code) — an 11-digit package-level code that encodes labeler, product, and package. A single branded atorvastatin 20 mg tablet product may appear under dozens of NDCs (different package sizes, different generic manufacturers, different lot codes). The fundamental RWE step is to map each NDC to its RxCUI at the SCD level, then navigate up to the IN level. Concretely: NDC 00071-0155-23 (Lipitor 20 mg, 90-count) → RxCUI 617310 (Lipitor 20 mg oral tablet, SBD) → RxCUI 308136 (atorvastatin 20 mg oral tablet, SCD) → RxCUI 83367 (atorvastatin, IN). This collapse is what allows an analyst to write "all atorvastatin fills" as a single ingredient RxCUI rather than maintaining a list of hundreds of NDCs that grows with every generic entrant.

Drug class expansion via RxClass

RxNorm's companion service, RxClass, links RxCUIs to external drug classification systems: ATC (Anatomical Therapeutic Chemical, WHO), MeSH pharmacologic actions, VA drug classes, and MED-RT (Medication Reference Terminology, the VA's successor to NDF-RT). An analyst building an "all statin" cohort queries RxClass for ATC C10AA (HMG CoA reductase inhibitors), retrieves all IN-level members, then maps each IN to its SCDs and NDCs. This makes the drug-class definition transparent, reproducible, and auditable — a critical property for regulatory submissions and HTA dossiers.

Operational realities for RWE analysts

Historical NDC-to-RxCUI mappings: Claims data often contain NDCs that have been withdrawn, retired, or reformulated. RxNav's `ndcstatus` and `historyndc` endpoints return the RxCUI that a retired NDC mapped to at the time it was active. Failing to use historical mappings causes unmapped NDCs (coded as "unclassified" or dropped), which selectively exclude older claims and biases incidence estimates. Retired and remapped RxCUIs: RxCUIs themselves can be retired or remapped across NLM monthly releases. A concept mapped in January may have a different status in December. Best practice is to pin the RxNorm release date (store it alongside every mapping), use the `rxcui/status` endpoint to detect remapped concepts, and rebuild mappings when updating to a new release. Suppressible and obsolete atoms: RxNorm marks certain atoms as suppressible (TTY=OA, etc.) or obsolete; analysts should filter to active, non-suppressible atoms when pulling name strings, and validate that the RxCUI returned by an NDC lookup has a current, non-retired status. OMOP integration: In the OMOP Common Data Model, RxNorm is the standard vocabulary for the Drug domain. Source NDC codes in DRUG_EXPOSURE are mapped to RxNorm standard concepts via `CONCEPT_RELATIONSHIP` rows with relationship `Maps to`. The DRUG_ERA derivation then rolls those clinical-drug concepts up to ingredient via `CONCEPT_ANCESTOR`. If a site's ETL mapped NDCs to non-standard concepts or used a stale RxNorm release, all downstream drug-era counts will be wrong; auditing the `vocabulary_id = 'RxNorm'` completeness in the CONCEPT table is the first QC step. ATC linkage for international comparability: RxClass provides RxCUI → ATC mappings. Because ATC is WHO's international classification, drug lists built this way can be ported to European claims or registry studies with minimal re-mapping — a common need in multi-country HEOR submissions.

Pros, cons, and trade-offs

- vs raw NDC lists maintained manually: RxNorm ingredient rollup is algorithmically stable: add one ingredient RxCUI to the concept set and every current and future NDC for that ingredient is automatically captured when the mapping files are refreshed. Manual NDC lists grow stale within months as new generics enter the market and old packages are retired. Prefer RxNorm rollup for any study that will be updated, replicated, or submitted to a regulator; maintain NDC-level granularity only when package-level identity matters (e.g., specific package sizes for adherence per-unit-cost calculations). The cost is that RxNorm mapping requires a one-time ETL investment and a versioning discipline that ad hoc NDC lists skip. - vs ATC alone for drug class identification: ATC is an internationally recognized hierarchical classification with clear levels (1-character to 7-character codes), making it natural for cross-country comparisons. RxNorm does not carry a built-in class hierarchy; class membership requires a RxClass lookup. However, ATC-to-NDC crosswalks are indirect and can miss new US generics not yet classified; RxNorm → RxClass → ATC is the more complete and timely path for US claims. Prefer RxNorm + RxClass for US-sourced studies; use ATC as the reporting layer when presenting alongside non-US data. - vs GPI (Generic Product Identifier) or Multum classification used in commercial claims: Some commercial claims databases (e.g., Truven MarketScan-era) ship with GPI or Multum class codes in addition to NDC. These are convenient shortcuts but are not publicly documented, not NLM-maintained, and not consistent across data vendors. Prefer RxNorm/RxCUI for reproducible, cross-database, or externally auditable work; GPI/Multum codes are acceptable for exploratory or single-database descriptive analyses where the code list will not be published or scrutinized. - vs brand-level HCPCS J-codes for physician-administered drugs: Many biologics and infused drugs dispensed in physician offices are billed under HCPCS Level II J-codes rather than pharmacy NDC codes. HCPCS J-codes do not appear in RxNorm; they live in a separate vocabulary (HCPCS in OMOP). An analyst building an "all adalimumab" cohort must union NDC-based pharmacy fills with HCPCS J-code-based medical claims — RxNorm handles only the former. Pair RxNorm with HCPCS J-codes for biologics and specialty infusibles; failing to do so undercounts exposure, especially in the Medicare and commercial fee-for-service populations where physician-office infusion is common.

When to use

Use RxNorm in any study that: (1) identifies drug exposure from pharmacy claims or EHR dispensing records by ingredient or drug class; (2) needs to collapse thousands of NDCs to a manageable concept list for cohort eligibility, washout, or outcome definitions; (3) will be run across multiple databases or replicated in a distributed research network (OMOP CDM sites, PCORnet, Sentinel) where a common vocabulary is required; (4) requires a defensible, publicly documented, and reproducible drug-class definition for a regulatory submission, HTA dossier, or peer-reviewed publication; (5) needs to check prior drug use in a washout period and must handle retired NDC codes from older claims.

When NOT to use — and when it is actively misleading or dangerous

- As the sole exposure source for physician-administered specialty drugs. Biologics, chemotherapy agents, and infused medications primarily appear in medical claims under HCPCS J-codes, not pharmacy NDC codes. Using only RxNorm/NDC-based fills to define "biologic initiator" in a rheumatology or oncology cohort will systematically miss the majority of infused administrations. The bias is differential if infusion rates differ by study arm or time period. - Without version-pinning the RxNorm release. Applying a drug list built against the January 2023 RxNorm release to claims processed in 2025 will silently miss NDCs for generics approved after January 2023 — and those generics may be the dominant dispensed form by 2025. Always store and report the RxNorm release date alongside every mapping. Studies spanning years should re-map with each annual or semi-annual release update. - Without auditing unmapped NDCs. Every NDC-to-RxCUI mapping exercise produces unmapped rows. If the unmapped fraction is substantial (often 3–15% in older claims) and non-random — for example, concentrated in a specific formulary tier, therapeutic class, or calendar period — the resulting exposure definition is biased and the selection is invisible in published code lists. Always report the fraction mapped and characterize unmapped NDCs before treating the mapping as complete. - Without the historical NDC endpoint for old claims. An NDC that appears in claims from 2015 may have been withdrawn and will not appear in current RxNorm active files. Mapping only against the current RxNorm release drops all withdrawn-NDC rows — which represent real fills — producing artificially low utilization in earlier calendar periods and inducing a secular trend artifact. - Treating a drug-class RxClass query as permanent. RxClass membership (especially via ATC updates or VA class revisions) can change across releases. A statin list queried in 2020 may differ slightly from one queried in 2024 if ATC added or reclassified agents. For regulatory work, snapshot and document the drug-class membership at query time.

Data-source operational depth

- Pharmacy claims (FFS commercial / Medicare Part D): The natural home for RxNorm mapping. Each adjudicated pharmacy claim carries an NDC; mapping to RxCUI is the first ETL step. Failure modes: claim reversals must be removed before the mapping step or a reversed fill appears as legitimate exposure; 90-day mail-order fills look identical to 30-day fills in terms of NDC but carry a larger `days_supply` that affects episode construction; free samples and manufacturer-provided doses appear in no pharmacy claim and are therefore invisible to RxNorm-based exposure windows. Medicare Advantage members' Part D utilization is often available only in encounter data rather than adjudicated FFS claims; verify that the Part D enrollment flag is present before trusting the NDC completeness for MA enrollees. - EHR (dispensing/prescribing records): EHR medication records may be coded in RxNorm natively (common in modern FHIR-based systems), in a source vocabulary (FDB, Multum) that RxNorm links, or as free-text strings requiring NLP normalization. Orders differ from fills; an EHR-recorded RxNorm code represents a prescription issued, not confirmed dispensing. For exposure definitions requiring confirmed fills, link EHR prescribing records to outpatient pharmacy claims. In-hospital administration records typically reference NDC or facility item codes, not RxNorm, and require a separate crosswalk step. - OMOP CDM sites (distributed network): The ETL from source NDC (or FDB/Multum concept) to OMOP standard RxNorm concept is the single most consequential data-quality step. Validate using the OHDSI Drug Exposure Diagnostics R package (`DrugExposureDiagnostics`): check the fraction of records with a valid `drug_concept_id > 0`, the ingredient-level coverage of your concept set, and the `days_supply` distribution for implausibles. When running across multiple CDM sites, pin the OMOP vocabulary release (CONCEPT table version) to ensure consistency; vocabulary version differences can cause a concept that maps in site A to fail in site B if site B's CONCEPT table predates the relevant RxNorm release. - Registry data: Disease registries rarely carry NDC-level dispensing. If the registry records treatment as a drug name string or ATC code, map through RxNorm (string-to-RxCUI via the RxNav approximate-match API, or ATC-to-RxCUI via RxClass) to enable cross-registry or claims linkage. The ATC-to-RxCUI path is usually more reliable than string matching for this purpose.

Worked example

Scenario

A pharmacoepidemiologist is building a "new statin user" cohort from commercial insurance pharmacy claims. The raw claims table contains NDC codes — one per prescription fill. Six fills appear for patient 3001 between January and June 2023, representing three different statin products from two different manufacturers in two different strengths. The analyst needs to: (1) map each NDC to its RxCUI, (2) identify which RxCUI grain corresponds to the generic clinical drug (SCD) and then the ingredient (IN), and (3) confirm all six fills belong to the same ingredient so they can be combined into one continuous statin exposure episode. The example walks through the mapping chain for each fill.

Dataset

Six pharmacy claim rows for one patient. Each NDC is a distinct product code (different manufacturer, strength, or package), yet all six represent statin fills that should collapse to a single ingredient for cohort purposes.

person_idfill_datendcproduct_descriptiondays_supply
30012023-01-0800071-0155-23Lipitor 20mg tab 90ct (Pfizer)90
30012023-01-0800071-0156-23Lipitor 40mg tab 90ct (Pfizer)90
30012023-02-1516729-0120-01atorvastatin 20mg tab 90ct (Accord)90
30012023-02-1516729-0121-01atorvastatin 40mg tab 90ct (Accord)90
30012023-04-1068180-0221-06atorvastatin 20mg tab 90ct (Lupin)90
30012023-06-0168180-0222-06atorvastatin 40mg tab 90ct (Lupin)90

Steps

  • Map each NDC to its SCD-level RxCUI using the RxNav API (or the RXNCONSO/RXNSAT files offline). 00071-0155-23 -> RxCUI 617310 (Lipitor 20 mg oral tablet, SBD) then its generic SCD RxCUI 308136 (atorvastatin 20 mg oral tablet). 00071-0156-23 -> RxCUI 617311 (Lipitor 40 mg oral tablet, SBD) -> SCD RxCUI 308137 (atorvastatin 40 mg oral tablet). The four generic NDCs map directly to SCD RxCUIs 308136 and 308137 without a branded intermediate.

  • Roll each SCD RxCUI up to the ingredient (IN) level via the "has_ingredient" relationship. Both SCD 308136 (atorvastatin 20 mg oral tablet) and SCD 308137 (atorvastatin 40 mg oral tablet) share the same ingredient RxCUI 83367 (atorvastatin, IN). All six NDCs therefore resolve to one and the same ingredient.

  • Count the distinct levels: 6 NDCs -> 2 SCDs (one per strength) -> 1 ingredient (atorvastatin). All six fills belong to a single ingredient concept, so they can be combined into one continuous statin exposure window for the new-user cohort definition.

  • Verify: the analyst queries RxNav for each NDC's status (to confirm no retired-NDC issue for these 2023 fills), checks the ingredient name is "atorvastatin" (RxCUI 83367), and records the RxNorm release date (e.g., 2023-07-03) used for the mapping so the study is reproducible.

Result

6 NDCs = 2 SCDs = 1 ingredient (atorvastatin, RxCUI 83367). All six pharmacy claim rows for patient 3001 map to the same ingredient. A washout query using RxCUI 83367 and its descendants in CONCEPT_ANCESTOR will capture all six product variants with a single concept set entry, eliminating the need to manually curate a 6-item NDC list that would grow stale as new generics enter the market.

Runnable example

python implementation

Two patterns: (A) real-time RxNav REST API — for each NDC, retrieve its RxCUI and walk up to ingredient level; suitable for small to medium volumes with caching. (B) offline RRF join — load RXNCONSO and RXNSAT from the NLM monthly release files and join...

import requests
import pandas as pd
from functools import lru_cache

RXNAV_BASE = "https://rxnav.nlm.nih.gov/REST"
RXNORM_RELEASE = "2024-01-03"   # pin the release date used for this mapping run


# ── PATTERN A: RxNav REST API (real-time, cached) ────────────────────────────

@lru_cache(maxsize=8192)
def ndc_to_rxcui(ndc: str) -> dict | None:
    """Return the current RxCUI and term type for an 11-digit NDC, or None if not found.
    Strips hyphens; tries historical lookup if current lookup returns nothing."""
    ndc = ndc.replace("-", "")
    # 1. Current NDC lookup
    r = requests.get(f"{RXNAV_BASE}/ndcstatus.json", params={"ndc": ndc}, timeout=10)
    if r.ok:
        data = r.json().get("ndcStatus", {})
        status = data.get("status", "")
        if status == "ACTIVE":
            rxcui = data.get("rxcui", "")
            if rxcui:
                return {"rxcui": rxcui, "status": "ACTIVE", "source": "current"}
    # 2. Historical NDC lookup for retired NDCs (common in older claims)
    r2 = requests.get(f"{RXNAV_BASE}/historyndc.json", params={"ndc": ndc}, timeout=10)
    if r2.ok:
        hist = r2.json().get("historicalNdcConcept", {}).get("historicalNdcTime", [])
        if hist:
            # return the most recent historical RxCUI
            latest = hist[-1]
            return {"rxcui": latest.get("rxcui", ""), "status": "HISTORICAL",
                    "source": "historyndc", "start": latest.get("startDate"),
                    "end": latest.get("endDate")}
    return None


@lru_cache(maxsize=4096)
def rxcui_to_ingredient(rxcui: str) -> str | None:
    """Walk from any drug RxCUI up to its ingredient (IN-level) RxCUI via has_ingredient."""
    r = requests.get(f"{RXNAV_BASE}/rxcui/{rxcui}/related.json",
                     params={"tty": "IN"}, timeout=10)
    if r.ok:
        groups = r.json().get("relatedGroup", {}).get("conceptGroup", [])
        for g in groups:
            if g.get("tty") == "IN":
                concepts = g.get("conceptProperties", [])
                if concepts:
                    return concepts[0]["rxcui"]   # first ingredient RxCUI
    return None


def map_claims_ndcs(claims_df: pd.DataFrame) -> pd.DataFrame:
    """Map a claims DataFrame with 'ndc' column to SCD-level RxCUI and ingredient RxCUI.
    Adds: rxcui_scd, rxcui_ingredient, ndc_status, rxnorm_release."""
    results = []
    for ndc in claims_df["ndc"].unique():
        lookup = ndc_to_rxcui(str(ndc))
        if lookup is None:
            results.append({"ndc": ndc, "rxcui_scd": None, "rxcui_ingredient": None,
                             "ndc_status": "UNMAPPED"})
            continue
        rxcui_scd = lookup["rxcui"]
        ing = rxcui_to_ingredient(rxcui_scd) if rxcui_scd else None
        results.append({"ndc": ndc, "rxcui_scd": rxcui_scd, "rxcui_ingredient": ing,
                         "ndc_status": lookup["status"]})
    mapping = pd.DataFrame(results)
    mapping["rxnorm_release"] = RXNORM_RELEASE
    merged = claims_df.merge(mapping, on="ndc", how="left")
    # Audit: report unmapped fraction
    n_total = len(merged)
    n_unmapped = merged["rxcui_scd"].isna().sum()
    print(f"NDC mapping: {n_total - n_unmapped}/{n_total} mapped "
          f"({100*(1 - n_unmapped/n_total):.1f}%); "
          f"{n_unmapped} unmapped ({100*n_unmapped/n_total:.1f}%)")
    return merged


# ── PATTERN B: Offline RRF join (bulk, reproducible) ─────────────────────────

def build_ndc_to_ingredient_from_rrf(
    rxnconso_path: str,   # path to RXNCONSO.RRF from the NLM release zip
    rxnsat_path: str,     # path to RXNSAT.RRF
) -> pd.DataFrame:
    """Build a local NDC -> ingredient mapping table from the downloaded NLM RRF files.
    Returns a DataFrame with columns: ndc, rxcui_scd, scd_name, rxcui_ingredient, ing_name."""

    # RXNCONSO columns (subset needed)
    conso_cols = ["RXCUI", "LAT", "TS", "LUI", "STT", "SUI", "ISPREF",
                  "RXAUI", "SAUI", "SCUI", "SDUI", "SAB", "TTY", "CODE",
                  "STR", "SRL", "SUPPRESS", "CVF"]
    # Load only the RxNorm source atoms, non-suppressible
    conso = pd.read_csv(rxnconso_path, sep="|", header=None, names=conso_cols,
                        usecols=["RXCUI", "SAB", "TTY", "STR", "SUPPRESS"])
    rxnorm_atoms = conso[(conso["SAB"] == "RXNORM") & (conso["SUPPRESS"] != "Y")].copy()

    # SCD and IN atoms
    scd_atoms = rxnorm_atoms[rxnorm_atoms["TTY"] == "SCD"][["RXCUI", "STR"]].rename(
        columns={"RXCUI": "rxcui_scd", "STR": "scd_name"})
    in_atoms = rxnorm_atoms[rxnorm_atoms["TTY"] == "IN"][["RXCUI", "STR"]].rename(
        columns={"RXCUI": "rxcui_ingredient", "STR": "ing_name"})

    # RXNSAT: NDC attribute (ATN='NDC') links a RxCUI to its NDC strings
    sat_cols = ["RXCUI", "LUI", "SUI", "RXAUI", "STYPE", "CODE", "ATUI",
                "SATUI", "ATN", "SAB", "ATV", "SUPPRESS", "CVF"]
    sat = pd.read_csv(rxnsat_path, sep="|", header=None, names=sat_cols,
                      usecols=["RXCUI", "ATN", "ATV", "SUPPRESS"])
    ndc_sat = sat[(sat["ATN"] == "NDC") & (sat["SUPPRESS"] != "Y")][["RXCUI", "ATV"]].rename(
        columns={"RXCUI": "rxcui_scd", "ATV": "ndc"})

    # Build SCD -> ingredient map via RXNREL (has_ingredient)
    # (For simplicity, use the CONCEPT_ANCESTOR-equivalent: any SCD's ingredient
    # is the IN atom whose RXCUI appears as the object of has_ingredient for that SCD.)
    # This minimal version reads it directly from RXNCONSO by matching SCD atoms to
    # their IN ancestors via RXNREL; a full implementation loads RXNREL.RRF similarly.
    # Here we return the NDC->SCD join as the core; the caller uses rxcui_to_ingredient()
    # for the SCD->ingredient step, or loads RXNREL.RRF in the same pattern.
    result = ndc_sat.merge(scd_atoms, on="rxcui_scd", how="left")
    print(f"Offline RRF: {len(result)} NDC->SCD rows loaded from {rxnsat_path}")
    return result
r implementation

Two patterns matching the Python version: (A) the RxNav REST API via httr2 with memoisation, returning RxCUI and ingredient rollup for a vector of NDCs; (B) a dplyr-based offline join against the downloaded RRF files. Both are minimal, commented snippets....

library(httr2)
library(dplyr)
library(readr)
library(memoise)

RXNAV_BASE <- "https://rxnav.nlm.nih.gov/REST"
RXNORM_RELEASE <- "2024-01-03"   # pin the release date


# ── PATTERN A: RxNav REST API with memoisation ───────────────────────────────

.ndc_to_rxcui_raw <- function(ndc) {
  ndc <- gsub("-", "", ndc)
  # 1. Current lookup
  resp <- request(paste0(RXNAV_BASE, "/ndcstatus.json")) |>
    req_url_query(ndc = ndc) |>
    req_timeout(10) |>
    req_error(is_error = \(r) FALSE) |>
    req_perform()
  if (resp_status(resp) == 200) {
    data <- resp_body_json(resp)$ndcStatus
    if (!is.null(data$rxcui) && nchar(data$rxcui) > 0 &&
        identical(data$status, "ACTIVE")) {
      return(list(rxcui = data$rxcui, status = "ACTIVE"))
    }
  }
  # 2. Historical lookup for retired NDCs
  resp2 <- request(paste0(RXNAV_BASE, "/historyndc.json")) |>
    req_url_query(ndc = ndc) |>
    req_timeout(10) |>
    req_error(is_error = \(r) FALSE) |>
    req_perform()
  if (resp_status(resp2) == 200) {
    hist <- resp_body_json(resp2)$historicalNdcConcept$historicalNdcTime
    if (length(hist) > 0) {
      latest <- hist[[length(hist)]]
      return(list(rxcui = latest$rxcui, status = "HISTORICAL"))
    }
  }
  list(rxcui = NA_character_, status = "UNMAPPED")
}
ndc_to_rxcui <- memoise(.ndc_to_rxcui_raw)  # cache within session

rxcui_to_ingredient <- memoise(function(rxcui) {
  # Walk from any RxCUI to its ingredient (IN) via the related endpoint
  resp <- request(paste0(RXNAV_BASE, "/rxcui/", rxcui, "/related.json")) |>
    req_url_query(tty = "IN") |>
    req_timeout(10) |>
    req_error(is_error = \(r) FALSE) |>
    req_perform()
  if (resp_status(resp) != 200) return(NA_character_)
  groups <- resp_body_json(resp)$relatedGroup$conceptGroup
  for (g in groups) {
    if (identical(g$tty, "IN") && length(g$conceptProperties) > 0)
      return(g$conceptProperties[[1]]$rxcui)
  }
  NA_character_
})

map_ndc_vector <- function(ndcs) {
  # Map a character vector of NDCs; returns a tibble with ndc, rxcui_scd, rxcui_ingredient
  purrr::map_dfr(unique(ndcs), function(ndc) {
    lookup   <- ndc_to_rxcui(ndc)
    ing_rxcui <- if (!is.na(lookup$rxcui)) rxcui_to_ingredient(lookup$rxcui) else NA_character_
    tibble::tibble(ndc = ndc, rxcui_scd = lookup$rxcui, rxcui_ingredient = ing_rxcui,
                   ndc_status = lookup$status, rxnorm_release = RXNORM_RELEASE)
  })
}


# ── PATTERN B: Offline RRF join (bulk) ───────────────────────────────────────

build_ndc_ingredient_map_rrf <- function(rxnconso_path, rxnsat_path) {
  # Load RXNCONSO - keep only non-suppressible RxNorm atoms at IN and SCD level
  conso <- read_delim(rxnconso_path, delim = "|", col_names = FALSE,
                      col_types = cols(.default = "c")) |>
    select(RXCUI = 1, SAB = 12, TTY = 13, STR = 15, SUPPRESS = 17) |>
    filter(SAB == "RXNORM", SUPPRESS != "Y")

  scd_atoms <- conso |> filter(TTY == "SCD") |>
    select(rxcui_scd = RXCUI, scd_name = STR)
  in_atoms  <- conso |> filter(TTY == "IN")  |>
    select(rxcui_ingredient = RXCUI, ing_name = STR)

  # Load RXNSAT - NDC attributes
  sat <- read_delim(rxnsat_path, delim = "|", col_names = FALSE,
                    col_types = cols(.default = "c")) |>
    select(RXCUI = 1, ATN = 9, ATV = 11, SUPPRESS = 12) |>
    filter(ATN == "NDC", SUPPRESS != "Y") |>
    rename(rxcui_scd = RXCUI, ndc = ATV)

  # Join NDC -> SCD
  result <- sat |>
    left_join(scd_atoms, by = "rxcui_scd")

  message(sprintf("Offline RRF: %d NDC->SCD rows loaded.", nrow(result)))
  result
}