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.6 for the typed rows, pytest>=8 for 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.

First-match-wins evaluation of the auto-verification decision table A result's condition vector — range out, delta pass, critical none, interference absent — is scanned top-down against the five decision-table rows. Rows one through three do not match. Row four, out-of-range but stable, is the first matching row and is highlighted; it decides the action HOLD. Row five is never reached. The matched row's action flows to the output disposition at the bottom. Condition vector range=out · delta=pass · critical=none · interference=absent 1 · interference=present → reject (no match) 2 · critical=breach → hold (no match) 3 · delta=fail → hold (no match) 4 · range=out, delta=pass → hold first matching row — decides 5 · range=in, all pass → verify (never reached) Disposition: HOLD routed to technologist review

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.

python
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.

python
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.

python
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.

python
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.

Part of: Auto-Verification Rule Configuration.