Standardizing Regulatory Taxonomies Across Global Trial Sites

Global clinical programs collect terms in dozens of local vocabularies that never line up cleanly across sites. This guide shows how to build a canonical concept model with stable IDs, deterministic crosswalks, SKOS-style match types, Unicode-safe label normalization, threshold-gated fuzzy synonym resolution, and versioned releases with reproducible hashes — so every site maps to one governed truth under a defensible audit trail.

A “taxonomy” in this context is a governed set of controlled vocabularies — adverse event terms, concomitant medication classes, lab parameters, visit names — that every site, region, and downstream system must agree on. The hard part is not collecting terms; it is reconciling the same concept expressed as Headache, headache, Céphalée, 頭痛, and a free-text bad head pain into one stable identifier without silently mapping things that are not actually equivalent. This page builds that reconciliation engine step by step.

This is the deepest how-to under Regulatory Taxonomy Standardization, which sits within Core Architecture & Regulatory Mapping for Clinical Trials. It pairs closely with Regulatory Data Dictionary Construction — the source of the variable-level definitions you map against — and FDA/EMA Submission Schema Design, the downstream consumer of standardized codes. Every standardization decision here is written to the append-only audit log that 21 CFR Part 11 requires.

Why naive approaches fail

Four shortcuts look reasonable on a single-site pilot and quietly corrupt a multi-region data set:

  • Comparing labels with str.lower(). Lowercasing is not enough for cross-locale equivalence. A full-width Japanese entry and its ASCII equivalent are bytewise distinct, the German ß does not fold under lower(), and combining-accent sequences compare unequal to their precomposed forms. Without NFKC normalization plus casefold(), Céphalée typed two different ways lands as two “different” terms and one concept fragments across sites.
  • Collapsing every mapping into “mapped/unmapped.” A site term that rolls up into a broader canonical concept is not interchangeable with it. Treating a broadMatch as if it were an exactMatch silently overstates equivalence and distorts every aggregate count downstream — the single most common cause of corrupted safety and efficacy roll-ups.
  • Auto-accepting high fuzzy scores. A 92% string similarity between two adverse-event verbatims is a candidate, not a fact. Auto-committing it injects a false equivalence that may not surface until a regulatory query months later. The cost is asymmetric: a missed candidate is a cheap manual review; a wrong auto-accept is a reportable data-integrity finding.
  • Serializing releases non-deterministically. If two engineers hash “the same” taxonomy and get different digests — because dict ordering, set ordering, or platform default encoding differ — you can never prove which mapping rules were in force when a submission was built. Determinism is not a nicety here; it is the entire basis of reproducibility under audit.

Standardization is therefore a layered model, not a lookup table:

  1. Canonical concepts with stable, opaque identifiers that never change meaning once published.
  2. Labels (preferred and alternate) attached to each concept, normalized only for comparison.
  3. Crosswalks that relate a site’s or region’s term set to canonical concepts with an explicit, typed relationship.
  4. Versioned releases so a mapping decision made in 2025 is reproducible in a 2028 inspection.
The layered standardization stack and what each layer guarantees Four stacked layers built from a base upward, each annotated with the guarantee it provides. Base: canonical concepts with opaque, stable identifiers guarantee immutable meaning. Above: labels normalized for comparison guarantee a stable comparison key. Above that: crosswalks with typed SKOS match edges guarantee typed equivalence. Top: versioned, hash-pinned releases guarantee reproducibility under audit. A left-hand arrow shows that each layer builds on the one below it. STANDARDIZATION STACK EACH LAYER GUARANTEES builds on the layer below Versioned releases hash-pinned, reproducible digests reproducibility under audit Crosswalks typed SKOS match edges from sources typed equivalence Labels preferred + alternate, normalized stable comparison key Canonical concepts opaque, stable identifiers immutable meaning

The relationship types come straight from SKOS (Simple Knowledge Organization System) mapping properties: exactMatch, closeMatch, broadMatch, narrowMatch, and relatedMatch. They encode how equivalent and how confident a mapping is — exactly the metadata a reviewer needs. An exactMatch is interchangeable; a broadMatch means the site term rolls up into a more general canonical concept; a narrowMatch is the inverse; relatedMatch is associative, not a substitution.

External standards we align to, accurately:

  • CDISC Controlled Terminology (CDISC CT), published quarterly by NCI EVS, supplies codelists for SDTM/CDASH variables (for example AESEV, CMDOSU). Each term carries an NCI “C-code” concept identifier — a ready-made stable ID we can adopt as the spine of our canonical model.
  • MedDRA is a hierarchical medical-event terminology (SOC → HLGT → HLT → PT → LLT). Site verbatim AE terms are coded to MedDRA Lowest Level Terms; the LLT-to-Preferred-Term relationship is itself a broadMatch in SKOS terms. MedDRA is licensed and versioned (for example 29.0); never embed its term text in code — reference codes and resolve against a licensed dictionary service.

We do not invent codes. Where a real standard provides an identifier, we adopt it; where it does not, we mint our own namespaced ID and record provenance.

Architecture overview

The resolution path is exact-first: the deterministic label index and curated crosswalks carry human certainty and run before any fuzzy logic. Only unresolved terms reach the scorer, and its output is a proposal routed to review — never an automatic standardization. Everything that resolves — or fails to — folds into a versioned release pinned by a content hash.

Exact-first resolution pipeline: deterministic layers before review-gated fuzzy matching Top-down decision flow. A site verbatim term is normalized then checked against the exact label index; a hit resolves to a canonical concept. A miss checks the curated crosswalk; a hit applies a typed match (exact, broad, narrow, related) and resolves to a canonical concept. A miss runs a rapidfuzz candidate search; scores at or above the cutoff are proposed for human review, scores below are quarantined as unresolved. Resolved concepts and reviewed proposals both feed a versioned release with a deterministic hash. A dashed divider separates the deterministic, human-curated layers above from the probabilistic, review-gated fuzzy layer below. EXACT-FIRST · DETERMINISTIC, HUMAN-CURATED FUZZY · PROBABILISTIC, REVIEW-GATED Site verbatim term Normalize label NFKC · casefold Exact label index hit? Crosswalk entry exists? Fuzzy candidate search rapidfuzz scorer Score ≥ cutoff? Quarantine as unresolved Resolve to canonical concept Apply typed match exact · broad · narrow · related Propose match for human review Versioned release deterministic hash yes no yes no resolve yes no after sign-off into release

Setup and configuration

The only third-party dependency is rapidfuzz for the candidate scorer; everything else is standard library.

pip install "rapidfuzz>=3.6"

Deprecated library warning. Do not use fuzzywuzzy (with python-Levenshtein) for the fuzzy layer — it is effectively unmaintained and licensing-encumbered. rapidfuzz is its maintained, permissively licensed successor with a compatible scorer set and far better performance. Treat any legacy from fuzzywuzzy import fuzz as technical debt to retire.

Configuration comes from the environment, never from literals in the source. The pipeline reads three values:

Variable Purpose Example
TAXONOMY_OPERATOR_ID Attributable identity written into every audit record svc-taxonomy-prod
TAXONOMY_AUDIT_LOG Path to the append-only JSON Lines audit trail /var/audit/taxonomy.jsonl
TAXONOMY_FUZZY_CUTOFF Score threshold below which nothing is proposed 90
import logging
import os

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
log = logging.getLogger("taxonomy.standardization")

OPERATOR_ID = os.environ["TAXONOMY_OPERATOR_ID"]        # fail fast if unset
AUDIT_LOG_PATH = os.environ["TAXONOMY_AUDIT_LOG"]
FUZZY_CUTOFF = float(os.environ.get("TAXONOMY_FUZZY_CUTOFF", "90"))

Reading TAXONOMY_OPERATOR_ID with os.environ[...] (not .get) is deliberate: a release built without an attributable operator has no place in a regulated system, so the process should refuse to start rather than emit anonymous audit records.

The canonical concept model

Start with an immutable concept record. The identifier is opaque and stable: once CTA:CONCEPT:000142 means “Headache,” it means that forever. Labels and relationships can be added, but the concept’s meaning cannot be silently repurposed.

from __future__ import annotations

import unicodedata
from dataclasses import dataclass, field
from enum import Enum


class MatchType(str, Enum):
    """SKOS mapping relationship types, ordered loosely by specificity."""

    EXACT = "exactMatch"
    CLOSE = "closeMatch"
    BROAD = "broadMatch"
    NARROW = "narrowMatch"
    RELATED = "relatedMatch"


def normalize_label(text: str) -> str:
    """Normalize a label for *comparison* (never for display or storage of source).

    Applies NFKC compatibility normalization, casefolds for case-insensitive
    matching, and collapses internal whitespace. NFKC folds compatibility
    variants (full-width characters, ligatures) to a canonical form so that
    a Japanese full-width entry and an ASCII entry compare equal.
    """
    if not isinstance(text, str):
        raise TypeError(f"label must be str, got {type(text).__name__}")
    folded = unicodedata.normalize("NFKC", text).casefold()
    return " ".join(folded.split())


@dataclass(frozen=True)
class Concept:
    """An immutable canonical concept with a stable identifier.

    `external_ids` carries provenance to real standards, e.g.
    {"NCIt": "C34661", "MedDRA_PT": "10019211"}.
    """

    concept_id: str
    pref_label: str
    domain: str  # e.g. "AE", "CM", "LB", "VS"
    external_ids: dict[str, str] = field(default_factory=dict)
    alt_labels: tuple[str, ...] = ()

    def all_labels(self) -> tuple[str, ...]:
        return (self.pref_label, *self.alt_labels)

normalize_label is deliberately separate from storage. We always keep the site’s original verbatim string for the audit trail and for MedDRA coding; the normalized form is only ever a comparison key. NFKC plus casefold() is the correct pairing for cross-locale equivalence — casefold() is more aggressive than lower() and handles cases like the German ß, while NFKC unifies full-width CJK and Latin compatibility forms that are visually identical but bytewise distinct.

Building the exact-match index and crosswalks

The fastest, safest resolution is an exact lookup on the normalized label. A crosswalk extends this: it maps a specific source vocabulary’s term to a canonical concept with an explicit MatchType, so a regional code that is broader than our concept is never treated as interchangeable.

from dataclasses import dataclass


@dataclass(frozen=True)
class CrosswalkEntry:
    """A typed mapping from a source term set to a canonical concept."""

    source_system: str   # e.g. "PMDA-LOCAL", "EMA-CT", "SITE-1042"
    source_term: str     # original source string, verbatim
    concept_id: str
    match_type: MatchType
    note: str = ""


class ConceptIndex:
    """In-memory index supporting exact and crosswalk resolution."""

    def __init__(self, concepts: list[Concept], crosswalks: list[CrosswalkEntry]):
        self._concepts: dict[str, Concept] = {c.concept_id: c for c in concepts}

        # Normalized label -> concept_id (exact-first resolution).
        self._label_index: dict[str, str] = {}
        for concept in concepts:
            for label in concept.all_labels():
                self._label_index.setdefault(normalize_label(label), concept.concept_id)

        # (source_system, normalized source term) -> crosswalk entry.
        self._crosswalk: dict[tuple[str, str], CrosswalkEntry] = {}
        for entry in crosswalks:
            if entry.concept_id not in self._concepts:
                raise ValueError(
                    f"crosswalk references unknown concept {entry.concept_id!r}"
                )
            key = (entry.source_system, normalize_label(entry.source_term))
            self._crosswalk[key] = entry

    def get_concept(self, concept_id: str) -> Concept:
        return self._concepts[concept_id]

    def resolve_exact(self, term: str) -> str | None:
        """Return a concept_id for an exact normalized-label hit, else None."""
        return self._label_index.get(normalize_label(term))

    def resolve_crosswalk(self, source_system: str, term: str) -> CrosswalkEntry | None:
        """Return a typed crosswalk entry for a known source term, else None."""
        return self._crosswalk.get((source_system, normalize_label(term)))

Two design rules matter here. First, exact-first: we always try the deterministic label index and explicit crosswalks before any fuzzy logic, because those carry human-curated certainty. Second, the index uses setdefault so the preferred label wins when two concepts share a normalized alternate label — a deterministic tie-break that keeps releases reproducible.

Fuzzy synonym resolution — threshold-gated and review-routed

Sites will always submit terms that match nothing exactly: typos, abbreviations, word-order swaps. Fuzzy matching with rapidfuzz surfaces candidates, but it must never auto-commit a mapping. It proposes; a human (or a higher-confidence downstream coder) disposes.

from dataclasses import dataclass

from rapidfuzz import fuzz, process


@dataclass(frozen=True)
class MatchCandidate:
    concept_id: str
    matched_label: str
    score: float


class FuzzyResolver:
    """Threshold-gated fuzzy candidate search over normalized labels.

    Exact resolution must be attempted *before* this; fuzzy results are
    proposals for human review, never automatic standardizations.
    """

    def __init__(self, index: ConceptIndex, *, score_cutoff: float = 90.0):
        if not 0.0 <= score_cutoff <= 100.0:
            raise ValueError("score_cutoff must be between 0 and 100")
        self._index = index
        self._score_cutoff = score_cutoff

        # Build a normalized-label -> concept_id choice map for the scorer.
        self._choices: dict[str, str] = {}
        for concept in index._concepts.values():
            for label in concept.all_labels():
                self._choices.setdefault(normalize_label(label), concept.concept_id)

    def propose(self, term: str, *, limit: int = 5) -> list[MatchCandidate]:
        """Return scored candidates at or above the cutoff, best first."""
        query = normalize_label(term)
        if not query:
            return []
        results = process.extract(
            query,
            self._choices.keys(),
            scorer=fuzz.token_sort_ratio,
            score_cutoff=self._score_cutoff,
            limit=limit,
        )
        candidates: list[MatchCandidate] = []
        for matched_label, score, _ in results:
            candidates.append(
                MatchCandidate(
                    concept_id=self._choices[matched_label],
                    matched_label=matched_label,
                    score=float(score),
                )
            )
        return candidates

token_sort_ratio tokenizes and sorts before comparing, so "pain head bad" and "bad head pain" score highly — useful for free-text verbatim AE entry. The score_cutoff is conservative on purpose: a missed candidate routes to manual review (cheap), while a wrong auto-accept corrupts aggregated safety data (expensive and potentially reportable). Tune the cutoff per domain — lab parameter names tolerate a higher bar than free-text adverse events.

The orchestration ties the layers together in strict priority order and emits a typed result rather than a bare string:

from dataclasses import dataclass
from enum import Enum
from typing import Optional


class Disposition(str, Enum):
    EXACT = "exact"
    CROSSWALK = "crosswalk"
    PROPOSED = "proposed_for_review"
    UNRESOLVED = "unresolved"


@dataclass(frozen=True)
class ResolutionResult:
    source_term: str
    disposition: Disposition
    concept_id: Optional[str] = None
    match_type: Optional[MatchType] = None
    score: Optional[float] = None


class StandardizationPipeline:
    def __init__(self, index: ConceptIndex, fuzzy: FuzzyResolver):
        self._index = index
        self._fuzzy = fuzzy

    def resolve(self, term: str, *, source_system: str) -> ResolutionResult:
        """Resolve a single site term, exact-first, then crosswalk, then fuzzy."""
        concept_id = self._index.resolve_exact(term)
        if concept_id is not None:
            return ResolutionResult(term, Disposition.EXACT, concept_id, MatchType.EXACT)

        entry = self._index.resolve_crosswalk(source_system, term)
        if entry is not None:
            return ResolutionResult(
                term, Disposition.CROSSWALK, entry.concept_id, entry.match_type
            )

        candidates = self._fuzzy.propose(term, limit=1)
        if candidates:
            top = candidates[0]
            return ResolutionResult(
                term, Disposition.PROPOSED, top.concept_id,
                match_type=MatchType.CLOSE, score=top.score,
            )

        return ResolutionResult(term, Disposition.UNRESOLVED)

Note the pipeline never silently invents an exactMatch from a fuzzy hit — fuzzy proposals are tagged PROPOSED with closeMatch and a score, and they require sign-off before they enter a crosswalk as a curated entry.

Versioned releases, deterministic hash, and the audit record

A taxonomy release is a frozen, signed artifact. Two engineers serializing the same set of concepts and crosswalks must produce the same hash, on any machine, in any year — otherwise you cannot prove which mapping rules were in force when a given submission was built. Determinism requires canonical serialization: sorted keys, sorted collections, and a fixed separator. Building a release also emits one immutable, hash-anchored audit trail record satisfying the ALCOA+ attributes.

import hashlib
import json
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path


def _canonical_concept(c: Concept) -> dict[str, object]:
    return {
        "concept_id": c.concept_id,
        "pref_label": c.pref_label,
        "domain": c.domain,
        "external_ids": dict(sorted(c.external_ids.items())),
        "alt_labels": sorted(c.alt_labels),
    }


def _canonical_crosswalk(e: CrosswalkEntry) -> dict[str, object]:
    d = asdict(e)
    d["match_type"] = e.match_type.value
    return d


def compute_release_hash(
    concepts: list[Concept], crosswalks: list[CrosswalkEntry]
) -> str:
    """Deterministic SHA-256 over the canonical content of a release."""
    payload = {
        "concepts": sorted(
            (_canonical_concept(c) for c in concepts),
            key=lambda d: d["concept_id"],
        ),
        "crosswalks": sorted(
            (_canonical_crosswalk(e) for e in crosswalks),
            key=lambda d: (d["source_system"], d["source_term"], d["concept_id"]),
        ),
    }
    encoded = json.dumps(
        payload, sort_keys=True, ensure_ascii=False, separators=(",", ":")
    ).encode("utf-8")
    return hashlib.sha256(encoded).hexdigest()


@dataclass(frozen=True)
class AuditRecord:
    """ALCOA+ audit entry appended when a release is cut."""

    action: str
    operator_id: str        # Attributable
    timestamp_utc: str      # Contemporaneous
    version: str
    content_hash: str       # Accurate / Original — pins exact rule set
    concept_count: int
    crosswalk_count: int


def emit_audit(record: AuditRecord, *, log_path: str) -> None:
    """Append one JSON Lines record to the append-only audit trail."""
    line = json.dumps(asdict(record), sort_keys=True, ensure_ascii=False)
    with Path(log_path).open("a", encoding="utf-8") as fh:
        fh.write(line + "\n")
    log.info("release %s audited hash=%s", record.version, record.content_hash[:12])


def build_release(
    version: str,
    concepts: list[Concept],
    crosswalks: list[CrosswalkEntry],
    *,
    operator_id: str,
    audit_log_path: str,
) -> dict[str, object]:
    """Assemble a release manifest with a content hash and emit an audit record."""
    content_hash = compute_release_hash(concepts, crosswalks)
    manifest: dict[str, object] = {
        "version": version,
        "released_at": datetime.now(timezone.utc).isoformat(),
        "content_hash": content_hash,
        "concept_count": len(concepts),
        "crosswalk_count": len(crosswalks),
    }
    emit_audit(
        AuditRecord(
            action="taxonomy.release.build",
            operator_id=operator_id,
            timestamp_utc=str(manifest["released_at"]),
            version=version,
            content_hash=content_hash,
            concept_count=len(concepts),
            crosswalk_count=len(crosswalks),
        ),
        log_path=audit_log_path,
    )
    return manifest

We sort every collection and use ensure_ascii=False with explicit UTF-8 encoding so that non-Latin labels (頭痛, Céphalée) hash identically regardless of platform default encoding. The content_hash becomes the pin you record in every submission package and audit record: it answers “exactly which taxonomy produced this code?” with cryptographic certainty.

Validation and edge-case handling

The failure modes from the opening section each map to a concrete guard:

  • Compatibility-variant duplicates. After building the index, assert that no two distinct concept IDs claim the same normalized label; a collision means the vocabulary has an ambiguity a human must resolve before release. The ConceptIndex setdefault keeps the first (preferred) winner deterministic, but you still want the collision surfaced, not swallowed.
  • Crosswalks pointing at deleted concepts. The ConceptIndex constructor raises ValueError if any CrosswalkEntry.concept_id is unknown — dangling references cannot enter a release.
  • exactMatch used where broadMatch is correct. No code can infer intent, so the crosswalk forces a MatchType on every entry and review sign-off is where the type is checked. Aggregation code must read the type and never treat broadMatch rows as interchangeable.
  • Hash drift. The determinism guarantee is only as good as your test for it — which is the next section.
def assert_no_label_collisions(concepts: list[Concept]) -> None:
    """Raise if two different concepts share a normalized label."""
    seen: dict[str, str] = {}
    for concept in concepts:
        for label in concept.all_labels():
            key = normalize_label(label)
            owner = seen.setdefault(key, concept.concept_id)
            if owner != concept.concept_id:
                raise ValueError(
                    f"label {label!r} maps to both {owner} and {concept.concept_id}"
                )

Testing and verification

Pin the two properties that matter most: normalization must unify cross-locale variants, and the release hash must be stable across serialization order.

import pytest


def _concepts() -> list[Concept]:
    return [
        Concept("CTA:CONCEPT:000142", "Headache", "AE",
                external_ids={"MedDRA_PT": "10019211"},
                alt_labels=("Céphalée", "頭痛")),
        Concept("CTA:CONCEPT:000501", "Nausea", "AE"),
    ]


def test_normalization_unifies_fullwidth_and_case():
    # Full-width Latin "Headache" folds to the same key as ASCII "HEADACHE".
    assert normalize_label("Headache") == normalize_label("HEADACHE")
    assert normalize_label("  Céphalée ") == "céphalée"


def test_exact_resolution_hits_alt_label():
    index = ConceptIndex(_concepts(), crosswalks=[])
    assert index.resolve_exact("頭痛") == "CTA:CONCEPT:000142"


def test_release_hash_is_order_independent():
    a, b = _concepts()
    xwalk = [CrosswalkEntry("PMDA-LOCAL", "頭痛", a.concept_id, MatchType.EXACT)]
    h1 = compute_release_hash([a, b], xwalk)
    h2 = compute_release_hash([b, a], xwalk)   # reversed input order
    assert h1 == h2


def test_fuzzy_never_auto_commits_exact():
    index = ConceptIndex(_concepts(), crosswalks=[])
    pipe = StandardizationPipeline(index, FuzzyResolver(index, score_cutoff=80.0))
    result = pipe.resolve("headake", source_system="SITE-1042")   # typo
    assert result.disposition is Disposition.PROPOSED
    assert result.match_type is MatchType.CLOSE   # not EXACT


def test_crosswalk_rejects_unknown_concept():
    with pytest.raises(ValueError):
        ConceptIndex(_concepts(),
                     [CrosswalkEntry("EMA-CT", "x", "CTA:CONCEPT:999999", MatchType.EXACT)])

test_release_hash_is_order_independent is the assertion that makes “reproducible in a 2028 audit” literally true: an auditor recomputing the hash from the archived concepts and crosswalks confirms the rule set has not drifted.

Governance, audit, and data-integrity alignment

Releases are governed, not ad hoc. The minimum controls:

  • Every new concept and crosswalk change goes through a regulatory-affairs review with a named approver and a recorded justification.
  • Concept identifiers are opaque and immutable; deprecation uses a supersededBy pointer, never reuse.
  • Each release pins the exact CDISC CT and MedDRA versions it was reconciled against (for example CDISC CT 2026-03-27, MedDRA 29.0).
  • Fuzzy PROPOSED matches are reviewed and either promoted to a curated CrosswalkEntry or rejected with a reason; auto-commit is prohibited.
  • The release content_hash is recorded in an append-only audit trail and referenced by every downstream submission.
  • The original verbatim site term is retained alongside the standardized concept for ALCOA+ attributability and traceability.

This satisfies the spirit of 21 CFR Part 11 audit-trail requirements and ICH E6(R3) / ICH E2B data-integrity expectations: every standardization decision is attributable, contemporaneous, original, accurate, and reproducible. The deterministic hash is what makes “reproducible” literally true — an auditor can recompute it years later and confirm the rule set has not drifted. For the upstream variable definitions these concepts attach to, see Regulatory Data Dictionary Construction; for how standardized codes flow into eCTD-aligned payloads, see FDA/EMA Submission Schema Design.

FAQ

How is this different from just coding to MedDRA?

MedDRA coding maps a single verbatim AE term to a MedDRA LLT/PT. The model here is broader: it governs all controlled vocabularies (AE, CM, LB, VS, visit names) across sites and regions, records the SKOS match type, and produces a versioned, hash-pinned release. MedDRA coding is one consumer of — and one external authority referenced by — this layer.

When should a match be broadMatch instead of exactMatch?

Use broadMatch when the source term rolls up into a more general canonical concept — for example a granular site term that corresponds to a parent concept. exactMatch means the two are interchangeable for analysis. Using exactMatch where broadMatch is correct silently overstates equivalence and distorts aggregation, which is why the crosswalk forces an explicit type on every entry.

Why gate fuzzy matching behind human review instead of auto-accepting high scores?

Because the cost is asymmetric. A missed fuzzy candidate routes to a cheap manual review; a wrong auto-accept injects a false equivalence into safety or efficacy aggregates that may not surface until a regulatory query. Threshold-gating plus mandatory review keeps the deterministic, auditable layers (exact and curated crosswalk) authoritative.

How do I handle a taxonomy update mid-study?

Cut a new versioned release, record its content_hash, and pin submissions to the version in force at the time. Never mutate a published release in place. Concepts that change meaning are deprecated with a supersededBy pointer and a new identifier, preserving the historical mapping for audit reconstruction.