Reflex Testing Routing Logic

Reflex routing is the one validation stage that does not gate a result — it reacts to one. When a primary result satisfies a predefined clinical condition, an abnormal TSH, a reactive HIV screen, a troponin above a rule-in threshold, the engine must automatically order a secondary test rather than merely flag the first. That makes reflex routing a producer of new orders inside a pipeline whose every other node is a classifier of existing ones, and it is exactly that asymmetry that makes it dangerous to build casually: a reflex fired twice orders a duplicate confirmatory assay on a patient, a reflex missed entirely leaves a reactive screen unconfirmed. This page specifies the routing contract, the reflex-rule model, the deterministic evaluation that keeps firing reproducible, and the tests that prove a primary result maps to the same reflex decision on every replay.

Context and Pipeline Position

Within the Clinical Result Validation & Rule Engine Architecture, reflex routing sits downstream of the numeric checks. A result reaches it already carrying a verdict from the Reference Range Check Implementation and, where a prior value existed, a delta assessment from Delta Validation & Trend Analysis. Reflex routing reads that classified result and asks a different kind of question: not “is this value safe to release?” but “does this value oblige the laboratory to run another test?” The two questions are independent — a result can be perfectly releasable and still reflex, as an abnormal TSH does — so reflex routing is a branch off the release path, not a checkpoint on it.

That independence is the defining architectural property. A critical-value breach escalates a result without changing what it is; a reflex condition emits a brand-new order for a different analyte on the same specimen or a fresh draw. Reflex routing therefore has two egress channels the other nodes lack: an order-emission channel into the LIS order queue, and the ordinary release channel the primary result continues down untouched. The decision bounds it evaluates are governed alongside the rest of the engine’s parameters by Threshold Tuning & Calibration, and the release disposition of the primary result — auto-verify or hold — is decided by the Auto-Verification Rule Configuration layer, which runs in parallel and never waits on the reflex.

Pipeline position of reflex routing A classified result flows from the reference range check through delta validation into reflex routing, the highlighted stage. Reflex routing reads its rules from a versioned, medical-director-approved reflex rule store and forks two ways: when a rule condition matches it emits a new reflex order into the LIS order queue, and independently the primary result continues down the ordinary release path whether or not a reflex fired. Range Check classified result Delta Validation trend analysis Reflex Routing condition → emit order branch, not a gate Reflex Order Emitted new order → LIS queue Primary Result Release unaffected by the reflex Reflex Rule Store versioned · MD-approved

Because it can emit orders, reflex routing is the node most likely to be built as an imperative tangle of if statements welded to the LIMS order API. Resisting that is the whole point: modeled as a stateless evaluation over a versioned rule set, reflex firing is reproducible; wired as bespoke triggers, it becomes an unbounded source of duplicate orders and unconfirmed reactives that no inspector can reconstruct.

Stage Boundaries

Reflex routing exposes one ingress contract and two egress contracts, plus a hold path for the cases where it cannot decide safely. Treat these as the only supported edges.

Ingress — the classified result. The node accepts a result that has already been classified: the numeric value and UCUM unit, the resolved loinc, the specimen and its identifier, the demographic context, and the flag assigned upstream (NORMAL, HIGH, CRITICAL_HIGH, and for qualitative assays a screening outcome such as REACTIVE or POSITIVE). It also accepts the specimen’s reflex_lineage — the set of orders already generated from this specimen — which is what lets the node refuse to reflex a result that is itself the product of a reflex.

Egress A — the reflex order event. When a rule condition matches and the guards pass, the node emits one or more ReflexOrder events, each naming the target_loinc, the orderable target_test_code, the specimen to draw from (an existing aliquot or a fresh collection), and the rule_id and rule version that fired. The emission is an append to the order queue; the node does not itself place the order in the LIS, it publishes an event the ordering service consumes idempotently.

Egress B — the pass-through. When no rule matches, or every matching rule is suppressed by a guard, the node emits a no_reflex outcome. This is not an error — most results reflex nothing — and it carries the evaluated rule_ids so the audit trail shows the reflex logic ran and chose not to fire, distinguishing a considered no-op from a skipped node.

Hold semantics. A rule can match while a precondition for safely ordering the reflex is missing — the specimen has insufficient volume for the target assay and no fresh-draw policy is configured, or the target test code is not resolvable in the current catalog. The node does not silently drop the reflex; it emits a REFLEX_REVIEW_REQUIRED outcome with a reason code so a technologist adjudicates. The controlling invariant mirrors the rest of the engine: a matched reflex that cannot be emitted is escalated, never dropped. An unconfirmed reactive result is a patient-safety event, not a queue nuisance.

Reflex Rule Specification

A reflex rule is a condition on a primary analyte’s result that names a secondary test to order. Laboratories accumulate dozens of these — endocrine cascades, infectious-disease confirmations, cardiac serials — and each must be version-controlled, effective-dated, and attributable to the medical director who approved it. The table below is the field contract for a single reflex rule row.

Field Type Semantics
rule_id string Stable identifier for the reflex rule; cited in every emitted order.
trigger_loinc string Primary analyte whose result is evaluated; the partition key for candidate lookup.
flag_in list[enum] Classification flags that arm the rule (HIGH, CRITICAL_HIGH, REACTIVE, …); empty means value-only.
operator enum Numeric comparator when value-gated: ge, le, gt, lt, eq, or any.
threshold Decimal? Bound the primary value is compared against; null when the rule is flag-only.
target_loinc string Analyte identity of the reflex test to order.
target_test_code string Orderable catalog code the LIS uses to place the reflex.
specimen_reuse bool Whether the reflex runs on an existing aliquot (true) or requires a fresh draw.
priority int Deterministic ordering when several rules match the same result; higher wins first.
max_cascade_depth int Guard: the deepest reflex chain allowed from an original order before firing is suppressed.
approved_by string Medical director attestation captured at rule promotion.
effective_from / effective_to date Version window used at replay time to select the historical rule.
version string Rule version identifier persisted with every emission for reproducibility.

Evaluation proceeds deterministically: select candidate rules by trigger_loinc, evaluate each candidate’s condition against the classified result (the flag must be in flag_in and — when a threshold is present — the value must satisfy operator threshold), then apply the guards. A rule that matches but whose reflex would exceed max_cascade_depth, or whose target_loinc already appears in the specimen’s reflex_lineage, is suppressed. Surviving rules emit in descending priority order so a cascade that fans out (an abnormal TSH ordering free T4 and a reflex antibody) fires in a stable sequence.

Reflex firing decision branch A classified result enters and candidate reflex rules are selected by trigger analyte. Each rule's condition is evaluated against the result flag and value. When no rule condition is met the result passes through as a considered no-op. When a condition is met the guards are checked: if the target has already been reflexed on this specimen or the cascade depth limit is reached the reflex is suppressed and audited, and otherwise a new reflex order is emitted with an audit event. All outcomes are recorded. classified result Select candidate rules rules where trigger_loinc matches Evaluate condition flag_in match · value operator threshold condition met no rule matches Guard checks not already reflexed depth < max_cascade_depth No-op pass-through result continues to release guard fails guards pass Suppress + audit duplicate or max depth Emit ReflexOrder new order + audit event Every outcome audited

The max_cascade_depth guard deserves emphasis. Reflex rules chain — a reactive HCV antibody reflexes to HCV RNA, and a laboratory could in principle configure the RNA result to reflex further. Without a depth bound, a mis-configured pair of mutually-triggering rules would emit orders until the queue saturated. Tracking depth on the specimen’s reflex_lineage and refusing to fire past the configured maximum turns a configuration mistake into a suppressed-and-audited event rather than an incident.

Implementation Patterns

Model the rule and the emitted order with Pydantic v2 so a malformed rule fails at load time and every emission serializes cleanly to the audit sink. Evaluation is a pure function of the classified result, the candidate rules, and the specimen lineage; nothing in the hot path performs I/O beyond the rule fetch.

python
from __future__ import annotations

from decimal import Decimal
from enum import Enum

from pydantic import BaseModel, ConfigDict, Field, model_validator


class Flag(str, Enum):
    NORMAL = "NORMAL"
    LOW = "LOW"
    HIGH = "HIGH"
    CRITICAL_LOW = "CRITICAL_LOW"
    CRITICAL_HIGH = "CRITICAL_HIGH"
    REACTIVE = "REACTIVE"
    POSITIVE = "POSITIVE"


class Operator(str, Enum):
    GE = "ge"
    LE = "le"
    GT = "gt"
    LT = "lt"
    EQ = "eq"
    ANY = "any"


class ClassifiedResult(BaseModel):
    model_config = ConfigDict(frozen=True, extra="forbid")

    loinc: str
    value: Decimal | None  # None for purely qualitative assays
    unit: str | None
    specimen_id: str
    flag: Flag
    reflex_lineage: tuple[str, ...] = ()  # target_loincs already ordered
    cascade_depth: int = Field(ge=0, default=0)


class ReflexRule(BaseModel):
    model_config = ConfigDict(frozen=True, extra="forbid")

    rule_id: str
    trigger_loinc: str
    flag_in: frozenset[Flag] = frozenset()
    operator: Operator = Operator.ANY
    threshold: Decimal | None = None
    target_loinc: str
    target_test_code: str
    specimen_reuse: bool = True
    priority: int = 0
    max_cascade_depth: int = Field(ge=1, default=3)
    version: str

    @model_validator(mode="after")
    def _check_condition(self) -> "ReflexRule":
        if self.operator is not Operator.ANY and self.threshold is None:
            raise ValueError(f"{self.rule_id}: operator {self.operator} needs a threshold")
        if not self.flag_in and self.operator is Operator.ANY:
            raise ValueError(f"{self.rule_id}: rule matches unconditionally")
        return self


class ReflexOrder(BaseModel):
    model_config = ConfigDict(frozen=True, extra="forbid")

    rule_id: str
    rule_version: str
    source_loinc: str
    source_specimen_id: str
    target_loinc: str
    target_test_code: str
    specimen_reuse: bool
    cascade_depth: int

The model_validator rejects a rule that would match unconditionally — a flag-less, operator-any rule reflexes every result of the trigger analyte, which is never the clinical intent and is caught at load rather than in production. Condition evaluation is a small, total function so it is trivially testable at the boundaries:

python
def condition_met(rule: ReflexRule, result: ClassifiedResult) -> bool:
    if rule.flag_in and result.flag not in rule.flag_in:
        return False
    if rule.operator is Operator.ANY:
        return True
    if result.value is None or rule.threshold is None:
        return False
    v, t = result.value, rule.threshold
    match rule.operator:
        case Operator.GE:
            return v >= t
        case Operator.LE:
            return v <= t
        case Operator.GT:
            return v > t
        case Operator.LT:
            return v < t
        case Operator.EQ:
            return v == t
    return False

Numeric comparisons use Decimal end to end so a threshold like 4.50 compares exactly against a result of 4.50; a float store would introduce representation error at precisely the boundary where the reflex decision flips. The guards are equally explicit, reading only the specimen lineage that arrived with the result:

python
def guards_pass(rule: ReflexRule, result: ClassifiedResult) -> str | None:
    """Return a suppression reason, or None when the reflex may fire."""
    if rule.target_loinc in result.reflex_lineage:
        return "already_reflexed"
    if result.cascade_depth + 1 > rule.max_cascade_depth:
        return "max_cascade_depth"
    return None

The evaluator ties them together and isolates the only I/O — fetching candidate rules — behind an async store, so a batch of classified results fans out across the event loop without hard-coding a rule source. Rules are sorted once, deterministically, before firing:

python
import asyncio
from typing import Protocol


class RuleStore(Protocol):
    async def candidates(self, trigger_loinc: str) -> list[ReflexRule]: ...


class ReflexEvaluator:
    def __init__(self, store: RuleStore) -> None:
        self._store = store
        self._sem = asyncio.Semaphore(64)

    async def evaluate(
        self, result: ClassifiedResult
    ) -> tuple[list[ReflexOrder], list[tuple[str, str]]]:
        async with self._sem:
            rules = await self._store.candidates(result.loinc)
        emitted: list[ReflexOrder] = []
        suppressed: list[tuple[str, str]] = []
        for rule in sorted(rules, key=lambda r: r.priority, reverse=True):
            if not condition_met(rule, result):
                continue
            reason = guards_pass(rule, result)
            if reason is not None:
                suppressed.append((rule.rule_id, reason))
                continue
            emitted.append(
                ReflexOrder(
                    rule_id=rule.rule_id,
                    rule_version=rule.version,
                    source_loinc=result.loinc,
                    source_specimen_id=result.specimen_id,
                    target_loinc=rule.target_loinc,
                    target_test_code=rule.target_test_code,
                    specimen_reuse=rule.specimen_reuse,
                    cascade_depth=result.cascade_depth + 1,
                )
            )
        return emitted, suppressed

Because condition_met and guards_pass are pure and the sort is stable and total, the same classified result against the same rule version set always yields the same orders in the same sequence — the reproducibility property an inspector relies on. Throughput scales with the rule-store cache hit rate rather than CPU, exactly as the reference range check does, and the two nodes share the same async batch processing fan-out discipline upstream.

Error Classification and Handling

Reflex errors tier the same way the range check’s do, and collapsing the tiers is how a laboratory either duplicates orders or loses a confirmatory.

Tier Example Disposition Retryable
Transport Rule store timeout, order queue publish failure Quarantine result, re-drive with backoff Yes
Schema Malformed rule row, non-UCUM threshold unit Reject rule at load, alarm to rule owner No
Semantic Target test code unresolvable, insufficient specimen REFLEX_REVIEW_REQUIRED, human adjudication No

Transport failures must never degrade into a silent no_reflex; the result is quarantined and re-driven, because a lost reflex on a reactive screen is indistinguishable, downstream, from a screen that was never reactive. Order emission is therefore idempotent: the ReflexOrder carries the rule_id, source_specimen_id, and target_loinc, and the consuming ordering service deduplicates on that key so a re-drive after a partial failure cannot place a second confirmatory. Schema failures are caught by Pydantic construction and the model_validator, keeping an unconditional or malformed rule out of the live set entirely. Semantic failures are the designed path to the review hold, each carrying a stable reason code (target_unresolvable, insufficient_specimen, no_fresh_draw_policy) so dashboards separate a catalog gap from an operational blip.

Regulatory Touchpoints

Reflex testing is a defined laboratory activity under the accreditation regimes, and each clause constrains the implementation above.

  • CLIA §493.1253 (established and verified test-system performance and procedures) requires that the reflex algorithm itself be a documented, verified procedure. The rule set is version-controlled and effective-dated so the procedure that fired for a given result is reconstructable, and approved_by records the medical director attestation the clause anticipates.
  • CLIA §493.1291(a) (test report) requires that reflex-generated results are reported and traceable to the primary order; the ReflexOrder linkage from source_specimen_id and rule_id preserves that parent-child chain into the report.
  • CAP GEN checklist and automated-verification requirements direct that reflex protocols be written, approved by the laboratory director, and reviewed for continued correctness; the same version-controlled rule store and REFLEX_REVIEW_REQUIRED egress that document the automated path also satisfy the requirement that failures route to human review, building on the CLIA/CAP Data Boundaries established at the platform layer.
  • 21 CFR Part 11 §11.10 demands attributable, tamper-evident records of the decision; every evaluation — fired, suppressed, or no-op — is written to an append-only sink with the rule version and specimen lineage, before the order is published.
  • HIPAA §164.312(b) audit controls apply because the reflex record carries PHI; access to the rule store and the audit sink is role-based and logged.

Because the reflex decision is itself an ordering action taken on a patient without a physician re-touching the chart, the audit event must be emitted before the order is published and must carry enough to replay the firing: the classified input, the resolved rule version, the guard outcome, and the emitted target.

Testing and Validation

Reflex behavior is only credible if the firing and the suppression are both proven. Combine property-based tests, golden-file cascade fixtures, and a rule-set contract test.

Property-based tests with hypothesis assert the invariants that must hold for every input — most importantly that a result already carrying its target in the lineage never reflexes again, and that firing is monotone in the threshold:

python
from decimal import Decimal

from hypothesis import given, strategies as st

from reflex import ClassifiedResult, Flag, Operator, ReflexRule, condition_met, guards_pass

decimals = st.decimals(min_value=0, max_value=1000, allow_nan=False, allow_infinity=False)


@given(value=decimals)
def test_ge_threshold_is_monotone(value: Decimal) -> None:
    rule = ReflexRule(
        rule_id="tsh-ft4", trigger_loinc="3016-3", flag_in=frozenset({Flag.HIGH}),
        operator=Operator.GE, threshold=Decimal("4.5"), target_loinc="3024-7",
        target_test_code="FT4", version="v1",
    )
    result = ClassifiedResult(
        loinc="3016-3", value=value, unit="m[IU]/L",
        specimen_id="S1", flag=Flag.HIGH,
    )
    assert condition_met(rule, result) == (value >= Decimal("4.5"))


@given(depth=st.integers(min_value=0, max_value=10))
def test_lineage_blocks_re_reflex(depth: int) -> None:
    rule = ReflexRule(
        rule_id="hcv", trigger_loinc="13955-0", flag_in=frozenset({Flag.REACTIVE}),
        target_loinc="11259-9", target_test_code="HCVRNA", version="v1",
    )
    result = ClassifiedResult(
        loinc="13955-0", value=None, unit=None, specimen_id="S2",
        flag=Flag.REACTIVE, reflex_lineage=("11259-9",), cascade_depth=depth,
    )
    assert guards_pass(rule, result) == "already_reflexed"

Golden-file fixtures pin real cascades — an abnormal TSH reflexing free T4, a reactive HCV antibody reflexing HCV RNA, a high troponin ordering a serial draw — as classified-input / expected-orders pairs, so any change in evaluation surfaces as a reviewable diff. A rule-set contract test loads the production reflex rules and asserts the safety invariants without touching live results: no rule matches unconditionally, every target_test_code resolves in the current catalog, no two rules form a cycle (rule A’s target is rule B’s trigger and vice versa), and every max_cascade_depth is at least one. Round-trip every ReflexRule and ReflexOrder through model_dump_json() / model_validate_json() so the audit payload deserializes back identically for the inspector who replays it later.

The two concrete cascades in this section have their own step-by-step implementations: Configuring TSH Reflex Cascades in a Python Rule Engine walks the endocrine chain, and Routing Hepatitis Panel Reflex Tests with Conditional Rules walks the infectious-disease confirmations and the re-reflex guard in detail.

Part of: Clinical Result Validation & Rule Engine Architecture.

Frequently Asked Questions

Does reflex routing gate whether the primary result is released?

No. Reflex routing is a branch off the release path, not a checkpoint on it. A result can auto-release and reflex at the same time — an abnormal TSH is reported while a free T4 order is emitted. The release disposition is decided independently by Auto-Verification Rule Configuration.

How does the engine avoid ordering the same reflex twice?

Each result carries its specimen’s reflex_lineage — the targets already ordered from that specimen — and a rule whose target_loinc is already in the lineage is suppressed with an already_reflexed reason. Order emission is also idempotent: the ordering service deduplicates on rule_id, source_specimen_id, and target_loinc, so a re-drive after a partial failure cannot place a second confirmatory.

What stops a reflex cascade from running away?

Every rule carries a max_cascade_depth, and the result tracks its cascade_depth. A rule that would push the depth past its maximum is suppressed and audited rather than fired, so a mis-configured pair of mutually-triggering rules produces a bounded, logged suppression instead of an unbounded flood of orders.

What happens when a rule matches but the reflex cannot be ordered?

The node emits a REFLEX_REVIEW_REQUIRED outcome with a reason code — target_unresolvable, insufficient_specimen, or no_fresh_draw_policy — and routes to human adjudication. A matched reflex that cannot be emitted is escalated, never silently dropped, because an unconfirmed reactive result is a patient-safety event.