This guide is part of the Grantor-Specific Rule Taxonomies section within the broader Core Architecture & Compliance Mapping framework, and it solves one narrow problem: how do you encode “this funder allows the de minimis indirect rate, that funder requires prior approval over $25,000, the other caps travel at 8%” so that the logic is reviewable by a compliance officer, deterministic on replay, and auditable to the exact rule that fired?
The reflex is a wall of nested if/elif blocks keyed on funder name. That structure hides its own precedence — whichever branch the author happened to write first silently wins — and no auditor can read Python to confirm that a 2024 award was scored under the 2024 ruleset. This guide replaces the branch tree with an explicit decision table: each row is a condition set mapped to an outcome with a numeric priority, the rows live as data a non-engineer can edit, the evaluator applies precedence with no hidden ordering, and a load-time scan rejects rows that overlap or contradict before they can ever fire.
When to Use This Approach
Reach for a decision table when all three of these hold:
- The rules change more often than the code ships. A funder revises a cost policy mid-cycle; a program officer clarifies that equipment over $5,000 needs prior approval. If those changes require a developer, a pull request, and a deploy, the ruleset lags reality. Rules-as-data means a compliance officer edits a reviewed file and the next evaluation picks it up — no branch logic touched.
- Every outcome must be defensible to an auditor. Cost-allowability determinations feed records governed by 2 CFR §200.302, so “the system decided” is not an answer. A decision table can name the exact row, its priority, and the ruleset version that produced any past outcome, which is what makes a determination replayable years later.
- Precedence between rules is a real question. When “federal award” and “no negotiated rate on file” both point at cost rules, something must decide which wins. Nested branches answer that by accident of authoring order. A table answers it with an explicit integer priority that a reviewer can see and challenge.
What is out of scope here: this page builds the evaluation engine, not the vocabulary of facts it consumes. Reconciling funder aliases and normalizing field names into the canonical grantee_type/cost_category keys the table reads belongs to the parent Grantor-Specific Rule Taxonomies section. Tracking whether a given organization is registered to receive funds in a jurisdiction belongs to State Charity Registration Compliance. Deriving the fact values themselves from filed returns belongs to IRS 990 Data Schema Mapping. The table assumes it is handed a clean, already-normalized fact set.
Step-by-Step Implementation
The reference implementation targets Python 3.10+ and uses only the standard library plus PyYAML for the human-editable ruleset file. Keep the engine dependency-light on purpose: the fewer moving parts, the easier it is to argue the evaluation is deterministic.
pip install "PyYAML==6.0.1"
# The evaluator itself is pure standard library (dataclasses, logging, hashlib).
One contract to internalize before writing code: a row is data, and precedence is a number, not a position. The engine never depends on the order rows appear in the file — it sorts by an explicit priority field. Reorder the YAML however a reviewer likes; the outcome does not move.
Step 1: Model each grantor rule as a data row
Start with two frozen dataclasses. A Condition binds one canonical fact to a small, closed vocabulary of operators, and a Rule is a set of conditions mapped to an outcome with an integer priority. Frozen dataclasses make a loaded ruleset immutable, so nothing can mutate a rule between load and evaluation.
import logging
from dataclasses import dataclass
from typing import Any, Mapping, Sequence
RULES_LOGGER = logging.getLogger("grant_rules.decision_table")
RULES_LOGGER.setLevel(logging.INFO)
_handler = logging.StreamHandler()
_handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s"))
RULES_LOGGER.addHandler(_handler)
_OPERATORS = frozenset({"eq", "lte", "gte", "in"})
@dataclass(frozen=True)
class Condition:
"""One fact tested against one value with a closed operator vocabulary."""
fact: str
op: str
value: Any
def matches(self, facts: Mapping[str, Any]) -> bool:
if self.fact not in facts:
return False
actual = facts[self.fact]
if self.op == "eq":
return actual == self.value
if self.op == "lte":
return actual <= self.value
if self.op == "gte":
return actual >= self.value
if self.op == "in":
return actual in self.value
raise ValueError(f"Unsupported operator {self.op!r} on fact {self.fact!r}")
@dataclass(frozen=True)
class Rule:
"""A condition set mapped to an outcome, ordered by explicit priority."""
rule_id: str
priority: int
conditions: Sequence[Condition]
outcome: Mapping[str, Any]
def matches(self, facts: Mapping[str, Any]) -> bool:
return all(cond.matches(facts) for cond in self.conditions)
The closed _OPERATORS set is the guardrail: a rule file can never smuggle in arbitrary Python, so the worst a bad row can do is fail validation loudly. Rule.matches is a plain conjunction — every condition must hold — which keeps each row readable as “all of these, then that.”
Step 2: Load the ruleset as reviewable data, not code
Keep the rules in a YAML file a compliance officer can read and diff in a pull request. The version is part of the payload, and it is the identifier every downstream determination pins to.
# grantor_rules.yaml
version: "2026.02"
rules:
- rule_id: R-030
priority: 30
conditions:
- {fact: has_nicra, op: eq, value: true}
outcome: {indirect_rate: negotiated, review: auto}
- rule_id: R-020
priority: 20
conditions:
- {fact: grantee_type, op: eq, value: federal}
- {fact: has_nicra, op: eq, value: false}
outcome: {indirect_rate: 0.10, review: auto}
- rule_id: R-000
priority: 0
conditions: []
outcome: {indirect_rate: null, review: manual}
The loader turns that mapping into typed Rule objects and raises a structured error — never a bare traceback from deep inside PyYAML — when a row is malformed.
class RulesetError(ValueError):
"""Raised when a ruleset payload is structurally invalid."""
def parse_rules(payload: Mapping[str, Any]) -> list[Rule]:
raw_rules = payload.get("rules")
if not isinstance(raw_rules, list) or not raw_rules:
raise RulesetError("Ruleset payload must contain a non-empty 'rules' list")
rules: list[Rule] = []
for idx, row in enumerate(raw_rules):
try:
conditions = tuple(
Condition(fact=c["fact"], op=c["op"], value=c.get("value"))
for c in row.get("conditions", [])
)
for cond in conditions:
if cond.op not in _OPERATORS:
raise RulesetError(f"Unknown operator {cond.op!r} in {row['rule_id']}")
rules.append(Rule(
rule_id=str(row["rule_id"]),
priority=int(row["priority"]),
conditions=conditions,
outcome=dict(row["outcome"]),
))
except (KeyError, TypeError) as exc:
raise RulesetError(f"Malformed rule at index {idx}: {exc}") from exc
return rules
Parsing to typed objects at the boundary means the rest of the engine never touches raw dictionaries, and a review comment on the YAML is a review comment on the live logic.
Step 3: Reject overlapping or contradictory rows at load time
The most dangerous failure is two rows at the same priority that can both match the same facts but disagree on the outcome — the engine would have to break the tie silently. Detect that at load time and refuse to build the table. This is the load-time conflict scan in the diagram.
def _mutually_exclusive(a: Condition, b: Condition) -> bool:
"""True if no fact value can satisfy both conditions on the same fact."""
if a.fact != b.fact:
return False
if a.op == "eq" and b.op == "eq":
return a.value != b.value
if {a.op, b.op} == {"eq", "in"}:
eq, coll = (a, b) if a.op == "eq" else (b, a)
return eq.value not in coll.value
return False # conservatively treat unknown pairings as overlapping
def _can_coexist(r1: Rule, r2: Rule) -> bool:
"""Two rules overlap unless some shared fact makes them exclusive."""
return not any(
_mutually_exclusive(c1, c2)
for c1 in r1.conditions for c2 in r2.conditions
)
def detect_conflicts(rules: Sequence[Rule]) -> list[tuple[str, str]]:
conflicts: list[tuple[str, str]] = []
for i, r1 in enumerate(rules):
for r2 in rules[i + 1:]:
if r1.priority == r2.priority and _can_coexist(r1, r2) \
and r1.outcome != r2.outcome:
conflicts.append((r1.rule_id, r2.rule_id))
return conflicts
The scan is deliberately conservative: when it cannot prove two conditions are mutually exclusive, it treats them as overlapping, so it will over-report rather than let an ambiguous pair through. False positives are cheap — split the priorities — while a missed conflict is a silent, non-deterministic determination.
Step 4: Evaluate deterministically with explicit precedence
Now assemble the DecisionTable. Its constructor sorts rules by descending priority (with rule_id as a stable tiebreaker so equal-priority survivors still order deterministically) and runs the conflict scan before the table is usable. Evaluation walks the sorted rows and returns the first match — no hidden ordering anywhere.
import hashlib
import json
from dataclasses import dataclass
@dataclass(frozen=True)
class Decision:
matched_rule_id: str
priority: int
outcome: Mapping[str, Any]
version: str
ruleset_hash: str
class DecisionTable:
"""Deterministic, versioned grantor rule evaluator."""
def __init__(self, version: str, rules: Sequence[Rule]) -> None:
conflicts = detect_conflicts(rules)
if conflicts:
raise RulesetError(f"Ambiguous equal-priority rows: {conflicts}")
# Explicit precedence: priority desc, then rule_id for a stable tiebreak.
self.version = version
self._rules = tuple(sorted(rules, key=lambda r: (-r.priority, r.rule_id)))
self.ruleset_hash = self._compute_hash()
def _compute_hash(self) -> str:
canonical = json.dumps(
[(r.rule_id, r.priority, [(c.fact, c.op, c.value) for c in r.conditions],
dict(r.outcome)) for r in self._rules],
sort_keys=True, default=str,
)
return hashlib.sha256(canonical.encode()).hexdigest()
def evaluate(self, facts: Mapping[str, Any]) -> Decision:
for rule in self._rules:
if rule.matches(facts):
return self._fire(rule)
raise RulesetError("No rule matched and no default row present")
Sorting once in the constructor and never again means every call to evaluate sees rows in identical order, so the same facts against the same table always fire the same row. The ruleset_hash fingerprints the exact rule content behind a version label.
Step 5: Pin every evaluation to a ruleset version for replay
A determination made in 2026 must be reproducible in 2029 under the 2026 rules, even after the file has changed many times. Keep a registry keyed by version, and pin each stored outcome to both the version and the content hash so a later replay can prove the ruleset was byte-for-byte identical. This is the traceability 2 CFR §200.302 expects of financial-management records.
class RuleRegistry:
"""Holds every published ruleset version for replay."""
def __init__(self) -> None:
self._tables: dict[str, DecisionTable] = {}
def register(self, table: DecisionTable) -> None:
if table.version in self._tables:
raise RulesetError(f"Version {table.version} already registered")
self._tables[table.version] = table
def evaluate_pinned(self, version: str, facts: Mapping[str, Any]) -> Decision:
table = self._tables.get(version)
if table is None:
raise RulesetError(f"No ruleset registered for version {version!r}")
return table.evaluate(facts)
Storing the returned Decision.ruleset_hash alongside each award record turns replay into a verifiable claim: re-register the archived YAML, re-evaluate, and confirm the hash matches. The determination that costs are allowable under 2 CFR §200.403 can then be reproduced exactly, not approximated.
Step 6: Log which row fired
Finally, make every determination self-documenting. _fire logs the rule, priority, version, and hash at INFO so the audit trail records not just the outcome but the precise row that produced it. Never emit the decision without the provenance.
def _fire(self, rule: Rule) -> Decision:
decision = Decision(
matched_rule_id=rule.rule_id,
priority=rule.priority,
outcome=dict(rule.outcome),
version=self.version,
ruleset_hash=self.ruleset_hash,
)
RULES_LOGGER.info(
"rule_fired | id=%s | priority=%d | version=%s | hash=%s | outcome=%s",
decision.matched_rule_id, decision.priority, decision.version,
decision.ruleset_hash[:10], json.dumps(decision.outcome, default=str),
)
return decision
One structured line per determination, carrying the truncated hash, is the record an auditor replays against. Ship it to a write-once tier so the trail is immutable.
Verification
Confirm the engine is deterministic and defensible with four checks:
- Precedence is explicit, not positional. Build a table, then build a second table from the same rules in reversed file order, and assert both fire the same
matched_rule_idfor the same facts. This proves ordering depends onpriority, not authoring position. - Conflicts are caught at load time. Feed two equal-priority rows that can both match and disagree on outcome, and assert the
DecisionTableconstructor raisesRulesetError— the table never becomes usable. - The default row always fires last. Pass facts that match no specific rule and assert the
priority: 0default (R-000) fires, never aRulesetErrorfor “no match.” - Replay is hash-stable. Register a version, capture a
Decision, rebuild the table from the archived YAML, and assert the freshruleset_hashequals the stored one byte-for-byte.
table = DecisionTable("2026.02", parse_rules(payload))
registry = RuleRegistry()
registry.register(table)
facts = {"grantee_type": "federal", "has_nicra": False, "award_amount": 180000}
decision = registry.evaluate_pinned("2026.02", facts)
assert decision.matched_rule_id == "R-020"
assert decision.outcome["indirect_rate"] == 0.10
assert len(decision.ruleset_hash) == 64
assert DecisionTable("2026.02", parse_rules(payload)).ruleset_hash == decision.ruleset_hash
Common Errors & Fixes
| Error | Cause | Fix |
|---|---|---|
RulesetError: Ambiguous equal-priority rows |
Two rows share a priority and can match the same facts with different outcomes | Give the more specific rule a higher priority, or add a distinguishing eq condition so the scan proves them exclusive. |
RulesetError: No rule matched and no default row present |
The table has no priority: 0 catch-all row |
Always include a default row with empty conditions, so unmatched facts route to manual review instead of raising. |
ValueError: Unsupported operator at evaluation |
A rule used an operator outside the closed vocabulary | Restrict rules to eq, lte, gte, in; add new operators to _OPERATORS and Condition.matches together, never one alone. |
| Replay hash does not match the stored value | The archived YAML was edited under the same version label | Treat version as immutable once published; publish edits as a new version and re-register, never mutate a released ruleset. |
| Wrong row fires after a “harmless” file reorder | Code relied on YAML order instead of priority |
Confirm every rule carries an explicit priority; the engine sorts on it, so ordering must live in the data, not the file. |
Related
- Parent section: Grantor-Specific Rule Taxonomies
- Where the canonical facts this table reads are registered and de-conflicted: State Charity Registration Compliance
- Where fact values are derived from filed returns: IRS 990 Data Schema Mapping