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.
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.
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.
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:
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:
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:
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_byrecords 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
ReflexOrderlinkage fromsource_specimen_idandrule_idpreserves 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_REQUIREDegress 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:
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.
Related
- Configuring TSH Reflex Cascades in a Python Rule Engine — the ordered endocrine cascade from abnormal TSH to free T4 and beyond, implemented step by step.
- Routing Hepatitis Panel Reflex Tests with Conditional Rules — reactive HBsAg and HCV antibody confirmations with an explicit re-reflex guard.
- Reference Range Check Implementation — the upstream classifier whose flag arms most reflex conditions.
- Critical Value Alert Routing — the parallel escalation branch a critical result takes independently of any reflex.
- Auto-Verification Rule Configuration — the layer that decides whether the primary result auto-releases while the reflex is emitted.
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.