Building an Auto-Verification Decision Table in Python
Problem Statement
A chain of nested if statements is the wrong shape for an auto-verification decision. As soon as range, delta, critical, and interference checks interact, the branches multiply, the ordering becomes implicit, and nobody can prove that every combination of check outcomes maps to exactly one disposition — which is precisely the proof a laboratory director needs before letting a machine release a result unattended. A decision table fixes the shape: it lays the checks out as columns, the rules out as rows, and the disposition in one final column, so the whole policy is visible at once, its precedence is the row order, and its completeness is a property you can test by enumerating the condition space. This page implements the auto-verify, hold, or reject decision as a deterministic decision table in Python, with an on-page table you can read, a typed evaluator that reproduces it exactly, and coverage tests that prove no combination of outcomes falls through a gap.
Prerequisites
- Runtime: Python 3.11+,
pydantic>=2.6for the typed rows,pytest>=8for the coverage fixtures. - Upstream verdicts: each check already produces a discrete outcome — the reference range check emits an in-range or out-of-range flag; critical value routing emits none or breach; the delta check emits pass or fail; an interference screen emits present or absent. The decision table consumes those enumerated outcomes, it does not compute them.
- Rule model: the actions and most-restrictive-wins composition defined in Auto-Verification Rule Configuration; this page is the tabular expression of that composition.
The Decision Table
Each condition column has a small, closed domain: range ∈ {in, out}, delta ∈ {pass, fail}, critical ∈ {none, breach}, interference ∈ {absent, present}. The range flag (in- or out-of-reference) and the critical flag (panic-bound breach) are separate columns because they come from separate subsystems — the range check and critical-value routing — rather than one combined verdict. A cell value of any is a wildcard that matches every value in that column. Rows are ordered by precedence, and evaluation is first-match-wins: the first row whose every cell matches the result’s outcomes decides the action. The rows are ordered most-restrictive-first so first-match and most-restrictive-wins produce the same disposition.
| # | range | delta | critical | interference | Action | Rationale |
|---|---|---|---|---|---|---|
| 1 | any | any | any | present | reject |
Interference invalidates the measurement; never release. |
| 2 | any | any | breach | absent | hold |
A critical breach always needs human eyes. |
| 3 | any | fail | none | absent | hold |
A failed delta signals a possible specimen or trend error. |
| 4 | out | pass | none | absent | hold |
Out-of-range but stable: hold for review, do not auto-release. |
| 5 | in | pass | none | absent | verify |
In-range, stable, uninterfered: the only auto-release path. |
The table is deliberately closed on the safe side: verify appears in exactly one row, and it requires every condition to be in its benign state. Every other combination resolves to hold or reject, so the auto-release path is a narrow, explicitly-enumerated exception rather than a default. Row 1 is first because interference is disqualifying regardless of anything else; rows 2 and 3 escalate the critical and delta failures ahead of the ordinary out-of-range hold in row 4.
Step-by-Step Implementation
Step 1: Model the condition domains and the row
Represent each condition as an enum with a closed domain so an impossible value cannot be constructed, and add a Match sentinel for the any wildcard. A DecisionRow is a Pydantic v2 model whose cells are either a concrete enum member or the wildcard.
from __future__ import annotations
from enum import Enum
from typing import Literal
from pydantic import BaseModel, ConfigDict
class Range(str, Enum):
IN = "in"
OUT = "out"
class Delta(str, Enum):
PASS = "pass"
FAIL = "fail"
class Critical(str, Enum):
NONE = "none"
BREACH = "breach"
class Interference(str, Enum):
ABSENT = "absent"
PRESENT = "present"
class Action(str, Enum):
VERIFY = "verify"
HOLD = "hold"
REJECT = "reject"
ANY = "any"
Cell = Literal["any"] # wildcard marker used alongside the concrete enums
class Outcomes(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
range: Range
delta: Delta
critical: Critical
interference: Interference
class DecisionRow(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
order: int
range: Range | Cell = ANY
delta: Delta | Cell = ANY
critical: Critical | Cell = ANY
interference: Interference | Cell = ANY
action: Action
def matches(self, o: Outcomes) -> bool:
return (
(self.range == ANY or self.range == o.range)
and (self.delta == ANY or self.delta == o.delta)
and (self.critical == ANY or self.critical == o.critical)
and (self.interference == ANY or self.interference == o.interference)
)
Step 2: Encode the table and evaluate first-match-wins
The table is a list of rows in precedence order. The evaluator sorts by order defensively, then returns the action of the first matching row. There is a final safety default: if no row matches — which the coverage test below proves cannot happen for this table — it holds rather than releases.
TABLE: list[DecisionRow] = [
DecisionRow(order=1, interference=Interference.PRESENT, action=Action.REJECT),
DecisionRow(order=2, critical=Critical.BREACH, action=Action.HOLD),
DecisionRow(order=3, delta=Delta.FAIL, action=Action.HOLD),
DecisionRow(order=4, range=Range.OUT, delta=Delta.PASS,
critical=Critical.NONE, interference=Interference.ABSENT,
action=Action.HOLD),
DecisionRow(order=5, range=Range.IN, delta=Delta.PASS,
critical=Critical.NONE, interference=Interference.ABSENT,
action=Action.VERIFY),
]
def evaluate(outcomes: Outcomes, table: list[DecisionRow] = TABLE) -> Action:
for row in sorted(table, key=lambda r: r.order):
if row.matches(outcomes):
return row.action
return Action.HOLD # unreachable for a complete table; fails safe
The evaluator is a pure function of the outcomes and the table, so it is trivially replayable: the same outcome vector against the same table version always yields the same action, and the audit sink only needs to store the outcome vector and the table version to reconstruct the disposition. This is the tabular realization of the most-restrictive-wins composition — because the rows are ordered restrictive-first, first-match never lets a verify row shadow a hold or reject that should have won.
Step 3: Prove completeness and no dead rows
A decision table is only trustworthy if two properties hold: completeness (every point in the condition space matches at least one row, so nothing falls through to the safety default silently) and no unreachable rows (every row can be the first match for some input, so a shadowed row is caught in review, not shipped). Both are provable by enumerating the finite condition space with itertools.product.
import itertools
def all_outcomes() -> list[Outcomes]:
return [
Outcomes(range=r, delta=d, critical=c, interference=i)
for r, d, c, i in itertools.product(Range, Delta, Critical, Interference)
]
def first_match_order(o: Outcomes, table: list[DecisionRow] = TABLE) -> int | None:
for row in sorted(table, key=lambda r: r.order):
if row.matches(o):
return row.order
return None
Verification & Testing
The two properties become two tests over the full 2×2×2×2 = 16-point condition space. A third test pins the specific dispositions that patient safety depends on.
import pytest
from decisiontable import (
Action, Critical, Delta, Interference, Outcomes, Range, TABLE,
all_outcomes, evaluate, first_match_order,
)
def test_table_is_complete() -> None:
"""Every combination of outcomes matches some row — no silent fall-through."""
unmatched = [o for o in all_outcomes() if first_match_order(o) is None]
assert unmatched == []
def test_no_unreachable_rows() -> None:
"""Every row is the first match for at least one outcome vector."""
reached = {first_match_order(o) for o in all_outcomes()}
declared = {row.order for row in TABLE}
assert declared <= reached
def test_only_benign_vector_auto_verifies() -> None:
"""verify is emitted for exactly one outcome vector, and it is the benign one."""
verifies = [o for o in all_outcomes() if evaluate(o) is Action.VERIFY]
assert verifies == [
Outcomes(range=Range.IN, delta=Delta.PASS,
critical=Critical.NONE, interference=Interference.ABSENT)
]
def test_interference_always_rejects() -> None:
for o in all_outcomes():
if o.interference is Interference.PRESENT:
assert evaluate(o) is Action.REJECT
Expected: all four pass. test_table_is_complete guarantees no combination reaches the safety default by accident; test_no_unreachable_rows guarantees no row is dead code shadowed by an earlier row; test_only_benign_vector_auto_verifies proves the auto-release path is the single, fully-benign vector; and test_interference_always_rejects proves the disqualifying condition dominates regardless of the other columns. Run these in CI on every table edit, because a change that widens the verify row or reorders precedence is exactly the change that must not ship unproven.
Compliance Note
A decision table satisfies the CAP GEN.41350 and CLIA §493.1256 expectation that auto-verification logic be documented and reproducible on review better than nested code can: the table is the documentation, the completeness test is the validation evidence, and the version stored with each disposition makes the exact policy reconstructable for the CLIA §493.1105 retention window. Because evaluate is a pure function of the outcome vector and the table version, the 21 CFR Part 11 §11.10 requirement for an attributable, reconstructable electronic decision is met by persisting only those two values to the append-only audit sink — the same discipline the reference range check applies to its verdicts.
Troubleshooting
A combination of outcomes falls through to the safety default.
Root cause: the table has a coverage gap — some point in the condition space matches no row — so it silently holds via the default. Fix: run test_table_is_complete, which enumerates all 16 outcome vectors and lists the unmatched ones; add or widen a row so every combination matches, and keep the test green in CI so a future edit cannot reopen the gap.
A row never fires no matter the input.
Root cause: an earlier, broader row with a wildcard shadows a later, more specific row, so first-match never reaches it. Fix: test_no_unreachable_rows flags the dead row; either move the specific row above the broader one or narrow the broader row’s wildcards so the intended precedence holds.
Two edits reorder the rows and the disposition changes silently.
Root cause: precedence lives in the row order, so a reorder is a policy change, but nothing pins the expected dispositions. Fix: keep explicit order integers rather than relying on list position, and pin the safety-critical dispositions with tests like test_only_benign_vector_auto_verifies and test_interference_always_rejects so any reorder that flips a disposition fails CI.
A new condition column doubles the table and coverage breaks.
Root cause: adding a condition column multiplies the condition space, and existing rows that omit the new column implicitly wildcard it, which can create gaps or new shadows. Fix: add the column to Outcomes and the row model, set the new cell explicitly on every row that should constrain it, and let test_table_is_complete and test_no_unreachable_rows re-prove completeness over the enlarged space before promotion.
Related
- Auto-Verification Rule Configuration — the rule model and most-restrictive-wins composition this decision table expresses in tabular form.
- Writing Auto-Verification Rules as Versioned YAML — the sibling authoring format for the same rules, with Git versioning and CI validation.
- Reference Range Check Implementation — the check that produces the range outcome the table consumes.
- Delta Validation & Trend Analysis — the check that produces the delta pass or fail outcome.
Part of: Auto-Verification Rule Configuration.