Routing Hepatitis Panel Reflex Tests with Conditional Rules
Problem Statement
Hepatitis serology is where the re-reflex bug bites hardest. A reactive hepatitis B surface antigen (HBsAg) screen must reflex to a neutralization confirmatory before it is reported as a true positive, and a reactive hepatitis C antibody must reflex to an HCV RNA nucleic-acid test to distinguish active infection from a resolved or false-positive antibody. Both are one-shot reflexes — but the products of those reflexes flow back through the same result pipeline, and a naïve engine happily re-evaluates the HBsAg neutralization result against the HBsAg reflex rule, or re-orders HCV RNA when the RNA result itself arrives, spawning duplicate confirmatories on an already-worked-up patient. The fix is not a special case bolted onto each rule; it is a lineage guard that refuses to reflex any result whose target already exists on the specimen. This page routes the hepatitis panel with deterministic conditional rules and makes that guard the centre of the design, so a reactive screen is confirmed exactly once and a confirmatory result never confirms itself.
Prerequisites
Before wiring hepatitis routing into the validation path, confirm the following baseline:
- Runtime: Python 3.11+,
pydantic>=2.6, andpytest>=8withpytest-asynciofor the fixtures. - Upstream classification: each serology result arrives classified, carrying a qualitative
flag(REACTIVEorNONREACTIVE) and, for the antibody assays, a signal-to-cutoff (s_co) ratio as aDecimal, with analyte identity resolved to LOINC by the Test Code Taxonomy & Standards service. - Routing host: the rules run inside the Reflex Testing Routing Logic node, which supplies the
ReflexOrdercontract and the specimenreflex_lineagethe guard reads. - Governance baseline: the reflex algorithm — reactive HBsAg to neutralization, reactive HCV antibody to HCV RNA — is documented and approved by the laboratory’s medical director, with an append-only audit sink for the CLIA retention window.
Step-by-Step Implementation
Step 1: Model the two hepatitis reflex rules
Each hepatitis reflex is a condition on a screening result naming a confirmatory to order. Model both as HepatitisRule rows keyed on the screening analyte. The HCV rule carries a signal-to-cutoff threshold so a weakly reactive antibody below the manufacturer’s confirmatory cutoff can be handled per the laboratory’s policy, while the HBsAg rule is purely qualitative on the REACTIVE flag.
from __future__ import annotations
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
HBSAG = "5195-3" # Hepatitis B surface antigen screen, serum
HBSAG_NEUT = "16933-4" # HBsAg neutralization confirmatory, serum
HCV_AB = "13955-0" # Hepatitis C antibody, serum
HCV_RNA = "11259-9" # Hepatitis C virus RNA, quantitative
class HepatitisRule(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
rule_id: str
trigger_loinc: str
reactive_flag: str = "REACTIVE"
min_s_co: Decimal | None = None # antibody signal-to-cutoff gate, if any
target_loinc: str
target_test_code: str
specimen_reuse: bool = True
version: str
HEPATITIS_RULES: tuple[HepatitisRule, ...] = (
HepatitisRule(
rule_id="hbv-neutralization", trigger_loinc=HBSAG,
target_loinc=HBSAG_NEUT, target_test_code="HBSAGCONF", version="hep-v3",
),
HepatitisRule(
rule_id="hcv-rna-reflex", trigger_loinc=HCV_AB,
min_s_co=Decimal("1.0"),
target_loinc=HCV_RNA, target_test_code="HCVRNA", version="hep-v3",
),
)
Step 2: Build the lineage guard against re-reflexing
The guard is the safety-critical part. A result may re-enter the pipeline for several reasons — the confirmatory itself resulted, a corrected value was issued, or a batch was replayed — and none of them should re-fire the screen’s reflex. The specimen’s reflex_lineage is the authoritative record of what has already been ordered from this specimen; a rule whose target_loinc is present there is suppressed. Equally, a confirmatory result must never match a screening rule, which the trigger-analyte keying already guarantees, but the guard is the defence in depth that catches a mis-keyed rule.
from enum import Enum
class Flag(str, Enum):
REACTIVE = "REACTIVE"
NONREACTIVE = "NONREACTIVE"
class SerologyResult(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
loinc: str
flag: Flag
s_co: Decimal | None = None
specimen_id: str
reflex_lineage: tuple[str, ...] = ()
cascade_depth: int = Field(ge=0, default=0)
def guard(rule: HepatitisRule, result: SerologyResult) -> str | None:
"""Return a suppression reason, or None when the reflex may fire."""
if result.loinc != rule.trigger_loinc:
return "wrong_trigger" # defence in depth against a mis-keyed rule
if rule.target_loinc in result.reflex_lineage:
return "already_reflexed" # the confirmatory was already ordered
if result.loinc == rule.target_loinc:
return "result_is_confirmatory" # never confirm a confirmatory
return None
Step 3: Evaluate the condition and emit the reflex
With the guard in place, the condition itself is small: the screen must be reactive, and where the rule sets min_s_co, the antibody signal-to-cutoff must meet it. A reactive HBsAg emits the neutralization confirmatory; a reactive HCV antibody at or above the cutoff emits HCV RNA. Emission is deterministic and depth-tracked so the confirmatory carries a depth one greater than its screen.
class ReflexOrder(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
rule_id: str
rule_version: str
source_loinc: str
target_loinc: str
target_test_code: str
specimen_id: str
cascade_depth: int
def condition_met(rule: HepatitisRule, result: SerologyResult) -> bool:
if result.flag.value != rule.reactive_flag:
return False
if rule.min_s_co is not None:
if result.s_co is None or result.s_co < rule.min_s_co:
return False
return True
def route_hepatitis(
result: SerologyResult,
rules: tuple[HepatitisRule, ...] = HEPATITIS_RULES,
) -> tuple[list[ReflexOrder], list[tuple[str, str]]]:
emitted: list[ReflexOrder] = []
suppressed: list[tuple[str, str]] = []
for rule in rules:
if rule.trigger_loinc != result.loinc:
continue
reason = guard(rule, result)
if reason is not None:
suppressed.append((rule.rule_id, reason))
continue
if not condition_met(rule, result):
continue
emitted.append(
ReflexOrder(
rule_id=rule.rule_id, rule_version=rule.version,
source_loinc=result.loinc, target_loinc=rule.target_loinc,
target_test_code=rule.target_test_code,
specimen_id=result.specimen_id,
cascade_depth=result.cascade_depth + 1,
)
)
return emitted, suppressed
The guard is evaluated before the condition on purpose: a confirmatory result that re-enters the pipeline should register a clear already_reflexed or result_is_confirmatory suppression in the audit, not silently fall through the reactivity check. That ordering is what turns the re-reflex hazard into an explicit, logged non-event.
Step 4: Emit an immutable audit record
Every routing decision — fired or suppressed — is written to the append-only sink so an inspector can see that a reactive screen was confirmed exactly once and that the confirmatory did not re-trigger.
import datetime as dt
class HepatitisRouteAudit(BaseModel):
trace_id: str
specimen_id: str
resulted_loinc: str
resulted_flag: Flag
s_co: Decimal | None
emitted: list[str]
suppressed: list[tuple[str, str]]
rule_version: str
routed_at: dt.datetime = Field(
default_factory=lambda: dt.datetime.now(dt.timezone.utc)
)
Verification & Testing
Prove that each screen confirms once and that no confirmatory re-confirms. pytest fixtures pin a reactive HBsAg that orders neutralization, a reactive HCV antibody above cutoff that orders RNA, a weakly reactive antibody below cutoff that does not, and the critical re-reflex case where the confirmatory result re-enters the pipeline and is suppressed.
def test_reactive_hbsag_orders_neutralization() -> None:
r = SerologyResult(loinc=HBSAG, flag=Flag.REACTIVE, specimen_id="S1")
emitted, _ = route_hepatitis(r)
assert [o.target_loinc for o in emitted] == [HBSAG_NEUT]
assert emitted[0].cascade_depth == 1
def test_reactive_hcv_above_cutoff_orders_rna() -> None:
r = SerologyResult(loinc=HCV_AB, flag=Flag.REACTIVE,
s_co=Decimal("3.4"), specimen_id="S1")
emitted, _ = route_hepatitis(r)
assert [o.target_loinc for o in emitted] == [HCV_RNA]
def test_hcv_below_cutoff_suppressed() -> None:
r = SerologyResult(loinc=HCV_AB, flag=Flag.REACTIVE,
s_co=Decimal("0.7"), specimen_id="S1")
emitted, suppressed = route_hepatitis(r)
assert emitted == []
def test_confirmatory_result_does_not_re_reflex() -> None:
# HBsAg screen re-enters after the neutralization was already ordered
r = SerologyResult(loinc=HBSAG, flag=Flag.REACTIVE, specimen_id="S1",
reflex_lineage=(HBSAG_NEUT,))
emitted, suppressed = route_hepatitis(r)
assert emitted == []
assert ("hbv-neutralization", "already_reflexed") in suppressed
Expected: all four pass. test_confirmatory_result_does_not_re_reflex is the one that matters most — it proves the lineage guard turns a re-entering screen into an explicit already_reflexed suppression rather than a duplicate confirmatory order. Round-trip HepatitisRouteAudit through model_dump_json() / model_validate_json() so the record deserializes back identically for later replay.
Compliance Note
CLIA §493.1253 requires the reflex algorithm to be an established, verified procedure; pinning the HepatitisRule set in a version-controlled configuration with a version on every row makes the exact confirmatory logic that fired reconstructable at inspection. The medical-director-approved reflex protocol — that a reactive HBsAg is not reported as positive until neutralization confirms it, and that a reactive HCV antibody is reflexed to RNA to establish active infection — is captured in the rule set and its approval metadata, satisfying the CAP requirement that automated reflex logic be documented and reviewable. Emitting a HepatitisRouteAudit for every routing decision and writing it to an append-only sink under the CLIA §493.1105 retention window satisfies the 21 CFR Part 11 expectation that the ordering decision be attributable and reconstructable. Because the confirmatory is ordered without a physician re-touching the chart, the audit must be written before the order is published.
Troubleshooting
The HBsAg neutralization confirmatory is ordered twice.
Root cause: the reactive HBsAg screen re-entered the pipeline — a corrected result or a batch replay — and re-matched the neutralization rule. Fix: the neutralization target LOINC is written to the specimen’s reflex_lineage when its order is emitted, so the guard returns already_reflexed on the second pass. The test_confirmatory_result_does_not_re_reflex fixture pins this behaviour; also confirm the downstream ordering service deduplicates idempotently on rule_id, specimen_id, and target_loinc.
HCV RNA is ordered on weakly reactive antibodies the lab wanted to hold.
Root cause: the HCV rule fired on the REACTIVE flag alone, ignoring the signal-to-cutoff ratio. Fix: set min_s_co on the hcv-rna-reflex rule to the laboratory’s confirmatory cutoff so a s_co below it is suppressed. The condition evaluates s_co only when min_s_co is configured, so purely qualitative assays are unaffected.
A confirmatory result triggers its own screening rule.
Root cause: a mis-keyed rule listed the confirmatory analyte as its trigger_loinc, or the confirmatory shares a code with the screen in the catalog. Fix: the guard’s wrong_trigger and result_is_confirmatory checks reject both cases before the condition is evaluated, and the rule-set contract test in the routing node asserts no rule’s target is any rule’s trigger, catching the cycle before promotion.
Nonreactive screens still generate audit noise.
Root cause: treating a nonreactive result as an error rather than a considered no-op. Fix: a nonreactive screen produces an empty emitted list and no suppression — the HepatitisRouteAudit records that routing ran and chose not to fire, which is the intended, low-signal audit entry distinguishing a considered no-op from a skipped stage.
Related
- Reflex Testing Routing Logic — the routing node whose
ReflexOrdercontract, specimen lineage, and rule-set contract test this page builds on. - Configuring TSH Reflex Cascades in a Python Rule Engine — the sibling endocrine cascade, where the same depth and lineage discipline drives an ordered multi-step reflex.
- Reference Range Check Implementation — the classifier that assigns the reactive and nonreactive flags this routing evaluates.
- Test Code Taxonomy & Standards — the LOINC identity resolution that keys every screening and confirmatory analyte.
Part of: Reflex Testing Routing Logic.