Skip to main content

Designing Decision Trees - Engineering Complex Branching Logic

Reading time: ~21 minutes | Level: Foundation → Engineering

What does this function return for income=45000, credit_score=720, employed=True, debt_ratio=0.55?

def approve_loan(income, credit_score, employed, debt_ratio):
if credit_score >= 700:
if employed:
if income >= 50000:
if debt_ratio < 0.4:
return "approved"
else:
return "conditional"
else:
if debt_ratio < 0.5:
return "conditional"
else:
return "rejected"
else:
return "rejected"
else:
if credit_score >= 600:
if employed and income >= 40000:
return "conditional"
else:
return "rejected"
else:
return "rejected"

The answer is "conditional". But the more pressing question is: how confident are you that this function handles all valid combinations correctly? Could you add a fifth condition - say, existing_loan=True - without introducing a regression? Would a new engineer understand what business rule each branch represents?

This is the problem that decision tree design solves. A decision tree is not an ML construct here - it is the hierarchical structure of conditions in your code that classifies inputs into outcomes. When that structure is designed deliberately, it is readable, testable, and extensible. When it grows organically, it becomes the most common source of production bugs.

What You Will Learn

  • What a decision tree in code actually is - and why every non-trivial function contains one
  • How to translate a real-world flowchart into clean Python using the loan approval example
  • Decision tables: representing M conditions × N outcomes explicitly to prevent missed cases
  • Implementing decision tables in Python as data structures rather than nested if blocks
  • How to order conditions for correctness first, then for performance
  • Completeness checking: proving that every case is handled
  • The "missing else" bug - a silent production failure you can detect and prevent
  • Separating condition logic from action logic for independent testability
  • Data-driven decision trees: storing rules in JSON and interpreting them at runtime
  • Combinatorial testing with itertools.product to cover all decision branches
  • Six interview questions covering the full engineering depth of this topic

Prerequisites

  • Python if/elif/else and guard clauses (Lessons 01 and 04 of this module)
  • Python dictionaries and lists (Module 2)
  • Basic function design (Module 3)
  • Python dataclasses for the data-driven section (Module 4)

Part 1 - What Is a Decision Tree in Code?

Every branch in your program is a node in a decision tree. Every outcome is a leaf. The total number of leaves equals the number of distinct outcomes your code can produce.

A decision tree with depth D and binary conditions can have up to 2^D leaves. With 4 binary conditions: 2^4 = 16 possible execution paths. Testing every path requires 16 distinct test cases.

The loan approval function at the top of this lesson has a depth of 4 nested conditions. That is up to 16 distinct execution paths - but not all are reachable. The challenge is knowing which paths exist, which are reachable, and which are handled correctly without tracing through the nesting manually.

Part 2 - From Flowchart to Python: The Loan Approval Example

The right starting point for any decision tree is a flowchart - a diagram of the real-world decision process. Here is the loan approval business logic as a flowchart, then as a decision table, then as clean Python code.

Step 1: The Business Flowchart

Step 2: The Decision Table

Before writing code, make every combination of conditions explicit. This is where you discover missing cases.

Credit ScoreEmployedIncomeDebt RatioOutcomeRule
>= 700Yes>= 50k< 0.4ApprovedR1
>= 700Yes>= 50k>= 0.4ConditionalR2
>= 700Yes< 50k< 0.5ConditionalR3
>= 700Yes< 50k>= 0.5RejectedR4
>= 700NoanyanyRejectedR5
600–699Yes>= 40kanyConditionalR6
600–699Yes< 40kanyRejectedR7
600–699NoanyanyRejectedR8
< 600anyanyanyRejectedR9

The table makes something immediately visible that the nested if code obscures: there are exactly 9 distinct rules. The code must handle all 9. Building the table first prevents the "missing else" bug - any unhandled combination becomes a visible gap in the table.

Step 3: Clean Python from the Decision Table

def approve_loan(income: float, credit_score: int, employed: bool, debt_ratio: float) -> str:
"""
Evaluate a loan application using explicit business rules.

Rules sourced from Decision Table v1.2 - Loan Approval Policy.
Each rule is commented with its table reference (R1–R9).
"""
# Rules R1–R5: High credit score path (>= 700)
if credit_score >= 700:
if not employed:
return "rejected" # R5

# Employed with high credit - now evaluate income and debt
if income >= 50_000:
if debt_ratio < 0.4:
return "approved" # R1
else:
return "conditional" # R2
else:
if debt_ratio < 0.5:
return "conditional" # R3
else:
return "rejected" # R4

# Rules R6–R8: Moderate credit score path (600–699)
if credit_score >= 600:
if employed and income >= 40_000:
return "conditional" # R6
else:
return "rejected" # R7 / R8

# Rule R9: Low credit score - blanket rejection
return "rejected"

This version is structurally identical to the opening function but is fundamentally different in how it was designed. The comment references (R1–R9) tie each branch to a documented business rule. The guard clause for employed eliminates one entire nesting level. The happy path logic appears at the point where decisions are made, not buried inside a pyramid.

Part 3 - Implementing Decision Tables in Python

For problems with many conditions and many outcomes, the code-as-table approach is more maintainable than nested if blocks. Store the decision logic as data.

from dataclasses import dataclass
from typing import Callable


@dataclass
class LoanApplication:
income: float
credit_score: int
employed: bool
debt_ratio: float


# Each rule is: (predicate function, outcome string)
# Rules are evaluated top to bottom; first matching rule wins.
LOAN_RULES: list[tuple[Callable[[LoanApplication], bool], str]] = [
# R1: High credit, employed, high income, low debt → approved
(lambda a: a.credit_score >= 700 and a.employed and a.income >= 50_000 and a.debt_ratio < 0.4,
"approved"),

# R2: High credit, employed, high income, moderate debt → conditional
(lambda a: a.credit_score >= 700 and a.employed and a.income >= 50_000,
"conditional"),

# R3: High credit, employed, lower income, low debt → conditional
(lambda a: a.credit_score >= 700 and a.employed and a.debt_ratio < 0.5,
"conditional"),

# R6: Moderate credit, employed, adequate income → conditional
(lambda a: 600 <= a.credit_score < 700 and a.employed and a.income >= 40_000,
"conditional"),

# R9 + R4 + R5 + R7 + R8: everything else → rejected
(lambda a: True,
"rejected"),
]


def evaluate_loan(application: LoanApplication) -> str:
"""Evaluate loan application against decision table rules."""
for predicate, outcome in LOAN_RULES:
if predicate(application):
return outcome
# This line is unreachable because the last rule always matches,
# but including it makes the function's contract explicit.
raise RuntimeError("Decision table is incomplete - no rule matched")


# Example
app = LoanApplication(income=45_000, credit_score=720, employed=True, debt_ratio=0.55)
print(evaluate_loan(app)) # "conditional"

:::tip Decision Table Advantages The rule list is data - it can be logged, debugged, persisted to a database, or edited by non-engineers via a UI. Adding a new rule requires inserting one tuple in the correct position in the list. There are no structural code changes required. This is the beginning of a rule engine. :::

Part 4 - Ordering Conditions for Correctness and Performance

Condition ordering has two distinct concerns that must not be confused.

Correctness ordering: More specific rules must appear before more general ones that would shadow them. This is a logical requirement - getting it wrong produces silent incorrect results.

Performance ordering: Among rules that are logically equivalent in position (where order does not affect which rule fires for any input), prefer to evaluate the cheapest check first.

# CORRECTNESS: most specific first
def classify_credit(score):
if score >= 750: # specific: excellent
return "excellent"
elif score >= 700: # less specific: good
return "good"
elif score >= 600: # less specific: fair
return "fair"
else: # catch-all: poor
return "poor"

# WRONG ORDER - correctness bug
def classify_credit_broken(score):
if score >= 600: # catches 750, 720, etc. before more specific cases
return "fair" # 780 → "fair" - WRONG
elif score >= 700: # unreachable for scores 600+
return "good"
elif score >= 750:
return "excellent"
else:
return "poor"
# PERFORMANCE: cheap checks first (within a group where order doesn't change correctness)
def validate_application(application):
# Cheap: attribute access and integer comparison
if application.credit_score < 300: # fast - disqualifies early
return "rejected"

# Moderate: boolean + arithmetic
if not application.employed and application.income < 20_000:
return "rejected"

# Expensive: external service call
if fraud_check_service.is_flagged(application.applicant_id):
return "rejected"

return "proceed_to_review"

Question: Can this condition disqualify a large fraction of inputs cheaply?

  • YES → put it first (high selectivity, low cost)
  • NO → defer until after cheaper checks pass
PriorityOperationTypical cost
1Attribute access / local variable readnanoseconds
2Integer or float comparisonnanoseconds
3String comparisonmicroseconds
4Dictionary lookupmicroseconds
5List search / membership testdepends on length
6Regex matchmicroseconds to milliseconds
7File I/O or network callmilliseconds to seconds

Part 5 - Completeness Checking: Ensuring All Cases Are Handled

A decision tree is complete if every possible input leads to a defined output. Incompleteness causes silent failures - the function returns None instead of raising an error, and the bug propagates invisibly through the system.

The Missing Else Bug

# INCOMPLETE - what if status is "pending"?
def process_status(status: str) -> str:
if status == "active":
return "process payment"
elif status == "inactive":
return "send reactivation email"
# Missing: "pending", "suspended", "deleted", and any future status

result = process_status("pending")
print(result) # None - silent failure
print(result.upper()) # AttributeError: 'NoneType' object has no attribute 'upper'

The error does not appear at the process_status call - it appears later, when the caller tries to use the return value. This is the worst kind of bug: the failure location is far from the defect.

Prevention strategies:

# Strategy 1: Exhaustive else - always define fallback behaviour
def process_status(status: str) -> str:
if status == "active":
return "process payment"
elif status == "inactive":
return "send reactivation email"
elif status == "pending":
return "await confirmation"
else:
raise ValueError(f"Unknown status: {status!r}") # fail loudly

# Strategy 2: Use an Enum to make the set of valid values finite and checkable
from enum import Enum, auto

class AccountStatus(Enum):
ACTIVE = auto()
INACTIVE = auto()
PENDING = auto()

def process_status(status: AccountStatus) -> str:
match status:
case AccountStatus.ACTIVE:
return "process payment"
case AccountStatus.INACTIVE:
return "send reactivation email"
case AccountStatus.PENDING:
return "await confirmation"
# No case _ here: if a new enum member is added and not handled,
# Python will fall through silently - add a guard:
case _:
raise ValueError(f"Unhandled status: {status}")

# Strategy 3: Assertion-based completeness check at module load time
_VALID_STATUSES = {"active", "inactive", "pending", "suspended"}

def process_status(status: str) -> str:
assert status in _VALID_STATUSES, f"Unknown status: {status!r}"
if status == "active":
return "process payment"
elif status == "inactive":
return "send reactivation email"
elif status == "pending":
return "await confirmation"
elif status == "suspended":
return "flag for review"
# The assert above guarantees we cannot reach here with an unexpected value

:::danger The Missing Else Is Silent Python returns None from any function that has no explicit return. If your if/elif chain does not cover all cases and the caller expects a string, you get None - which passes if result: checks silently (since None is falsy), propagating the failure further. Always include an explicit else: raise ValueError(...) when your decision tree must be exhaustive. :::

Part 6 - Separating Condition Logic from Action Logic

A fundamental engineering principle for decision trees is that the code which decides what to do should be separated from the code that does it.

# MIXED: condition logic and action logic interleaved - hard to test each independently
def handle_loan_application(application):
if application.credit_score >= 700 and application.employed:
send_approval_email(application.email)
create_loan_record(application.id, "approved")
notify_underwriting_team(application.id)
elif application.credit_score >= 600:
send_conditional_email(application.email)
create_loan_record(application.id, "conditional")
else:
send_rejection_email(application.email)
create_loan_record(application.id, "rejected")
log_rejection_reason(application.id, application.credit_score)


# SEPARATED: classify first (pure function, easily testable), act second
def classify_application(application) -> str:
"""Pure function - no side effects. Returns a classification string."""
if application.credit_score >= 700 and application.employed:
return "approved"
elif application.credit_score >= 600:
return "conditional"
else:
return "rejected"


def execute_decision(application, decision: str) -> None:
"""Action function - has side effects, called after classification."""
match decision:
case "approved":
send_approval_email(application.email)
create_loan_record(application.id, "approved")
notify_underwriting_team(application.id)
case "conditional":
send_conditional_email(application.email)
create_loan_record(application.id, "conditional")
case "rejected":
send_rejection_email(application.email)
create_loan_record(application.id, "rejected")
log_rejection_reason(application.id, application.credit_score)
case _:
raise ValueError(f"Unknown decision: {decision!r}")


def handle_loan_application(application) -> None:
"""Orchestrator: classify, then act."""
decision = classify_application(application)
execute_decision(application, decision)

The separated design allows classify_application to be tested with pure inputs and outputs - no mocking required. You can enumerate dozens of test cases as simple (input, expected_output) tuples. The action function can be tested separately with mocked services.

Part 7 - Data-Driven Decision Trees

When decision logic must be configurable without code changes - for example, when different clients have different approval criteria - the rules can be stored in JSON and interpreted at runtime.

import json
from dataclasses import dataclass
from typing import Any


RULES_JSON = """
[
{
"id": "R1",
"conditions": [
{"field": "credit_score", "op": ">=", "value": 700},
{"field": "employed", "op": "==", "value": true},
{"field": "income", "op": ">=", "value": 50000},
{"field": "debt_ratio", "op": "<", "value": 0.4}
],
"outcome": "approved"
},
{
"id": "R2",
"conditions": [
{"field": "credit_score", "op": ">=", "value": 700},
{"field": "employed", "op": "==", "value": true},
{"field": "income", "op": ">=", "value": 50000}
],
"outcome": "conditional"
},
{
"id": "R_default",
"conditions": [],
"outcome": "rejected"
}
]
"""


def evaluate_condition(record: dict, condition: dict) -> bool:
"""Evaluate a single condition against a data record."""
field_value = record[condition["field"]]
op = condition["op"]
threshold = condition["value"]

match op:
case ">=": return field_value >= threshold
case "<=": return field_value <= threshold
case ">": return field_value > threshold
case "<": return field_value < threshold
case "==": return field_value == threshold
case "!=": return field_value != threshold
case _: raise ValueError(f"Unknown operator: {op!r}")


def evaluate_rules(record: dict, rules: list[dict]) -> str:
"""
Evaluate a record against a list of rules.
Returns the outcome of the first rule whose all conditions match.
"""
for rule in rules:
if all(evaluate_condition(record, cond) for cond in rule["conditions"]):
return rule["outcome"]
raise RuntimeError("Decision table is incomplete - no rule matched")


rules = json.loads(RULES_JSON)
application = {
"credit_score": 720,
"employed": True,
"income": 45_000,
"debt_ratio": 0.35
}

print(evaluate_rules(application, rules)) # "conditional" (R2 matches first)

:::note When Data-Driven Makes Sense Use data-driven decision logic when: rules change frequently (avoiding deployments for rule changes), different clients need different rule sets, non-engineers need to update rules via a UI, or audit requirements demand a record of which rule version was in effect at each decision point. For stable, rarely-changing business logic, hardcoded if/elif is simpler and faster. :::

Part 8 - When to Use a Real ML Decision Tree vs a Handcrafted One

Use a Handcrafted Decision Tree whenUse an ML Decision Tree when
Business rules are explicitly knownRules emerge from data patterns
Rules are stable and documentedRules are too complex for humans to specify
Auditability and explainability requiredYou have labelled training data available
Regulatory compliance requires traceabilityFeature interactions are non-obvious
Simple enough to be understood and reviewedDataset has hundreds of features

A handcrafted loan approval tree is appropriate when the bank's policy is documented: "approve if credit score > 700 and debt ratio < 0.4". An ML tree is appropriate when you have thousands of historical applications and want to discover what combination of 50+ features actually predicts default - patterns that no human analyst would enumerate manually.

Part 9 - Testing Decision Logic: Combinatorial Coverage

For a decision tree with N binary conditions, full combinatorial coverage requires 2^N test cases. Use itertools.product to generate them systematically.

import itertools

def test_loan_classifier_coverage():
"""
Generate all combinations of boundary values for each condition
and verify the classifier produces a defined, non-None output.
"""
# Boundary values for each parameter (not exhaustive - boundary testing)
credit_score_values = [599, 600, 699, 700, 750]
employed_values = [True, False]
income_values = [39_999, 40_000, 49_999, 50_000, 75_000]
debt_ratio_values = [0.39, 0.40, 0.49, 0.50, 0.60]

results = {}
for credit_score, employed, income, debt_ratio in itertools.product(
credit_score_values, employed_values, income_values, debt_ratio_values
):
from dataclasses import dataclass

@dataclass
class App:
credit_score: int
employed: bool
income: float
debt_ratio: float

app = App(credit_score, employed, income, debt_ratio)
result = approve_loan(income, credit_score, employed, debt_ratio)

# Every combination must produce a defined outcome
assert result in ("approved", "conditional", "rejected"), \
f"Unexpected result {result!r} for {app}"
results[(credit_score, employed, income, debt_ratio)] = result

print(f"Tested {len(results)} combinations - all produced valid outcomes")
# Count outcomes for distribution analysis
from collections import Counter
counter = Counter(results.values())
print(f"Distribution: {dict(counter)}")

test_loan_classifier_coverage()

:::tip Boundary Value Analysis Test at the boundary of each condition, not just arbitrary values. For credit_score >= 700, test 699 (just below) and 700 (at boundary). For debt_ratio < 0.4, test 0.399 and 0.400. Bugs almost always live at boundaries, not in the middle of a range. :::

Part 10 - Boolean Blindness and Other Pitfalls

Boolean blindness occurs when conditions return True/False but you lose the information about what was tested:

# Boolean blindness: what does True mean here?
def is_eligible(application):
return (application.credit_score >= 600 and
application.employed and
application.income >= 40_000)

result = is_eligible(app)
if result:
approve(app)
else:
reject(app) # WHICH condition failed? You don't know.

A richer approach returns structured information:

from dataclasses import dataclass

@dataclass
class EligibilityResult:
eligible: bool
reason: str

def check_eligibility(application) -> EligibilityResult:
if application.credit_score < 600:
return EligibilityResult(False, f"Credit score {application.credit_score} below minimum 600")
if not application.employed:
return EligibilityResult(False, "Applicant is not employed")
if application.income < 40_000:
return EligibilityResult(False, f"Income {application.income} below minimum 40000")
return EligibilityResult(True, "All criteria met")

result = check_eligibility(app)
if result.eligible:
approve(app)
else:
reject(app, reason=result.reason) # now you know why

Hidden state affecting conditions: if a condition function has side effects or reads mutable global state, the same input can produce different outputs at different times. Condition functions should be pure.

Ordering bugs that shadow cases: placing a broad condition before a specific one makes the specific case unreachable. This was covered in Part 4 but is worth repeating - it is the second most common decision tree bug after missing else.

Interview Questions

Q1: What is a decision table, and why is it useful before writing code?

Answer: A decision table is a structured representation of all combinations of conditions and their corresponding outcomes. Rows represent rules (combinations of condition values), columns represent conditions and the outcome. The value of building the table before writing code is that it makes every combination explicit and visible. When you write nested if code directly, you can accidentally leave some combinations unhandled. The table forces you to think about every row - including the ones that might only occur in edge cases in production. It also serves as documentation that ties each code branch to a named business rule.

Q2: How do you verify that a decision tree is complete - that it handles every possible input?

Answer: Completeness means every possible input leads to a defined output. Verification strategies include: (1) Exhaustive else clause - always end with else: raise ValueError(...) so unhandled cases fail loudly rather than silently returning None; (2) Enum-based input - restrict the input domain to a finite set using an Enum, then verify every enum member is handled; (3) Decision table cross-check - count the rows in your decision table and count the branches in your code - they should match; (4) Combinatorial testing - use itertools.product to generate all combinations of boundary values and assert each produces a defined outcome.

Q3: How should you order conditions for performance, and does this conflict with ordering for correctness?

Answer: Performance ordering and correctness ordering solve different problems and can sometimes conflict. Correctness ordering requires specific conditions before general ones - a broad if score >= 600 must come after if score >= 700, not before. Performance ordering prefers cheap, high-selectivity checks early - an attribute access that rejects 80% of inputs in nanoseconds should run before an external API call. When both apply: first satisfy correctness ordering (this is non-negotiable), then within each group of conditions that are logically interchangeable in order, apply performance ordering. Document your ordering rationale with comments so future maintainers do not inadvertently swap conditions for efficiency and break correctness.

Q4: What is a data-driven decision tree, and when should you use one instead of hardcoded if/elif logic?

Answer: A data-driven decision tree stores the rule set as data (JSON, YAML, a database table) and uses an interpreter function to evaluate each rule against the input at runtime. This is appropriate when: (1) rules change frequently - you update the JSON without touching Python code or doing a deployment; (2) different clients or tenants need different rule sets - load the rule set for each tenant dynamically; (3) non-technical users need to modify rules via a UI that writes to the JSON source. Hardcoded if/elif is better when rules are stable and well-understood, the rule set is small, performance is critical (no interpreter overhead), and auditability requires code review rather than data review.

Q5: How do you test a function that contains only boolean conditions - one that returns True/False?

Answer: Boolean predicate functions require testing at boundaries - the exact input values where the function transitions from False to True. For every condition of the form x >= N, test N-1 (False), N (True), and N+1 (True). For combined conditions, test each independently: (1) all True; (2) first condition False, all others True; (3) second condition False, all others True; etc. For N binary conditions you need at minimum N+1 tests to cover all ways exactly one condition can fail. Use itertools.product to generate the full combinatorial matrix for comprehensive coverage. Never test with arbitrary "middle" values - bugs live at boundaries.

Q6: What is the principle of separating condition logic from action logic, and why does it improve testability?

Answer: The principle states that the code which decides what to do (a pure function returning a classification or decision) should be separate from the code that does it (functions with side effects like writing to a database or sending emails). The testability benefit is significant: a pure classifier function can be unit-tested with a simple input-output table - no mocking, no setup, no teardown. You can write 50 test cases as assert classify(input) == expected. The action functions can be tested independently with mocked dependencies. When they are combined, testing either one requires setting up the other's mocked infrastructure, which slows tests and makes failures harder to attribute to the correct component.

Quick Reference Cheatsheet

ConceptWhat it solvesKey technique
Decision tableMissing cases, undocumented rulesBuild M-condition × N-outcome table before coding
Guard clausesDeep nesting from validationEarly return on failure at top of function
Correctness orderingWrong branch fires for specific inputsMost specific condition first
Performance orderingSlow checks run for rejected inputsCheapest / most-selective check first
Missing elseSilent None returnselse: raise ValueError(...)
Boolean blindnessCan't tell why condition failedReturn structured result with reason field
Data-driven rulesRules change without code deploymentStore rules as JSON, use interpreter function
Completeness checkNot all inputs handledEnum input types + case _: raise
Combinatorial testingNot all branches exerciseditertools.product on boundary values
Separation of concernsCondition and action code hard to testclassify() (pure) + execute() (side effects)

Graded Practice Challenges

Level 1 - Predict the Output

def classify(score, verified, balance):
if score >= 700:
if verified:
return "gold"
return "silver"
if score >= 500:
if balance > 1000:
return "silver"
return "bronze"
return "rejected"

print(classify(750, False, 5000))
print(classify(750, True, 0))
print(classify(600, False, 2000))
print(classify(400, True, 9999))
Show Answer

Output:

silver
gold
silver
rejected
  • classify(750, False, 5000): score >= 700 is True, verified is False, so the first if in that branch fails - returns "silver" from the bare return after the inner if.
  • classify(750, True, 0): score >= 700 is True, verified is True, returns "gold". balance is never checked.
  • classify(600, False, 2000): score >= 700 is False, score >= 500 is True, balance > 1000 is True (2000 > 1000), returns "silver". verified is never checked.
  • classify(400, True, 9999): score >= 700 is False, score >= 500 is False, returns "rejected". Neither verified nor balance is checked.

Notice that verified only matters in the high-score path and balance only matters in the mid-score path - a decision table would make this explicitly clear.

Level 2 - Debug the Code

This discount calculator has a bug. Some inputs produce incorrect discounts, and one input produces None. Find and fix both issues.

def calculate_discount(years_customer: int, annual_spend: float, vip: bool) -> float:
"""
Discount policy:
- VIP + 5+ years + spend > 10000: 25%
- VIP + spend > 10000: 20%
- 5+ years + spend > 5000: 15%
- spend > 5000: 10%
- otherwise: 5%
"""
if vip and years_customer >= 5 and annual_spend > 10_000:
return 0.25
elif annual_spend > 5_000: # BUG: this shadows the VIP 20% and 15% rules
return 0.10
elif vip and annual_spend > 10_000:
return 0.20
elif years_customer >= 5 and annual_spend > 5_000:
return 0.15
# BUG: no else/default - returns None for low spenders
Show Answer

Bugs:

  1. The elif annual_spend > 5_000: condition fires before the VIP 20% and 15-year 15% rules, making them unreachable.
  2. No else clause - calculate_discount(1, 1000, False) returns None.

Fixed version (decision table order: most specific first, always exhaustive):

def calculate_discount(years_customer: int, annual_spend: float, vip: bool) -> float:
"""
Discount policy - rules ordered from most specific to most general.
Rule R1 (most specific) must precede R2, R2 before R3, etc.
"""
# R1: VIP + long tenure + high spend (most specific - all three criteria)
if vip and years_customer >= 5 and annual_spend > 10_000:
return 0.25

# R2: VIP + high spend (two criteria)
elif vip and annual_spend > 10_000:
return 0.20

# R3: Long tenure + decent spend (two criteria - different combination)
elif years_customer >= 5 and annual_spend > 5_000:
return 0.15

# R4: Decent spend only (one criterion)
elif annual_spend > 5_000:
return 0.10

# R5: Catch-all default (always covers remaining cases)
else:
return 0.05


# Verify
print(calculate_discount(6, 15_000, True)) # 0.25 (R1)
print(calculate_discount(2, 15_000, True)) # 0.20 (R2)
print(calculate_discount(6, 7_000, False)) # 0.15 (R3)
print(calculate_discount(1, 7_000, False)) # 0.10 (R4)
print(calculate_discount(1, 1_000, False)) # 0.05 (R5)

Level 3 - Design Challenge

Implement a configurable decision table engine that reads rules from a JSON configuration and evaluates them against input records. The engine must:

  1. Load rules from a JSON string (format: list of {id, conditions, outcome})
  2. Each condition has {field, op, value} where op is one of >=, <=, >, <, ==, !=, in
  3. A rule matches if ALL its conditions match (AND logic)
  4. Return the outcome of the first matching rule
  5. Raise a RuntimeError if no rule matches (incomplete table)
  6. Support a special "in" operator that checks if field_value in value (where value is a list)

Test your engine with at least two rule configurations: the loan approval table and a shipping tier table.

Show Reference Solution
import json
import operator
from typing import Any


# Operator mapping - maps op string to callable
OPERATORS = {
">=": operator.ge,
"<=": operator.le,
">": operator.gt,
"<": operator.lt,
"==": operator.eq,
"!=": operator.ne,
"in": lambda val, container: val in container,
}


def evaluate_condition(record: dict[str, Any], condition: dict) -> bool:
"""Evaluate a single condition dict against a record dict."""
field = condition["field"]
op = condition["op"]
threshold = condition["value"]

if field not in record:
raise KeyError(f"Condition references field {field!r} which is not in record")

op_fn = OPERATORS.get(op)
if op_fn is None:
raise ValueError(f"Unknown operator {op!r}. Supported: {list(OPERATORS.keys())}")

return op_fn(record[field], threshold)


def evaluate_rules(record: dict[str, Any], rules: list[dict]) -> dict:
"""
Evaluate a record against a rule list.
Returns the full matching rule dict (including 'id' and 'outcome').
Raises RuntimeError if no rule matches.
"""
for rule in rules:
if all(evaluate_condition(record, cond) for cond in rule["conditions"]):
return rule
raise RuntimeError(
f"Decision table is incomplete - no rule matched for record: {record}"
)


# ── Loan Approval Rules ──────────────────────────────────────────────────────
LOAN_RULES = json.loads("""[
{
"id": "R1",
"description": "High credit, employed, high income, low debt",
"conditions": [
{"field": "credit_score", "op": ">=", "value": 700},
{"field": "employed", "op": "==", "value": true},
{"field": "income", "op": ">=", "value": 50000},
{"field": "debt_ratio", "op": "<", "value": 0.4}
],
"outcome": "approved"
},
{
"id": "R2",
"description": "High credit, employed, high income",
"conditions": [
{"field": "credit_score", "op": ">=", "value": 700},
{"field": "employed", "op": "==", "value": true},
{"field": "income", "op": ">=", "value": 50000}
],
"outcome": "conditional"
},
{
"id": "R_default",
"description": "All other cases",
"conditions": [],
"outcome": "rejected"
}
]""")

# ── Shipping Tier Rules ───────────────────────────────────────────────────────
SHIPPING_RULES = json.loads("""[
{
"id": "S1",
"description": "Priority member with heavy item",
"conditions": [
{"field": "membership", "op": "==", "value": "priority"},
{"field": "weight_kg", "op": ">", "value": 20}
],
"outcome": "freight_priority"
},
{
"id": "S2",
"description": "Priority member",
"conditions": [
{"field": "membership", "op": "==", "value": "priority"}
],
"outcome": "express"
},
{
"id": "S3",
"description": "Ship to allowed regions with light item",
"conditions": [
{"field": "region", "op": "in", "value": ["US", "CA", "MX"]},
{"field": "weight_kg", "op": "<=", "value": 5}
],
"outcome": "standard"
},
{
"id": "S_default",
"description": "Everything else",
"conditions": [],
"outcome": "manual_review"
}
]""")


# ── Tests ─────────────────────────────────────────────────────────────────────
loan_tests = [
({"credit_score": 720, "employed": True, "income": 60_000, "debt_ratio": 0.35}, "approved"),
({"credit_score": 720, "employed": True, "income": 60_000, "debt_ratio": 0.45}, "conditional"),
({"credit_score": 720, "employed": True, "income": 45_000, "debt_ratio": 0.35}, "rejected"),
({"credit_score": 550, "employed": True, "income": 80_000, "debt_ratio": 0.20}, "rejected"),
]

for record, expected in loan_tests:
result = evaluate_rules(record, LOAN_RULES)
outcome = result["outcome"]
status = "PASS" if outcome == expected else "FAIL"
print(f"[{status}] Rule {result['id']}: {outcome} (expected {expected})")

shipping_tests = [
({"membership": "priority", "weight_kg": 25, "region": "US"}, "freight_priority"),
({"membership": "priority", "weight_kg": 3, "region": "UK"}, "express"),
({"membership": "standard", "weight_kg": 2, "region": "CA"}, "standard"),
({"membership": "standard", "weight_kg": 50, "region": "UK"}, "manual_review"),
]

for record, expected in shipping_tests:
result = evaluate_rules(record, SHIPPING_RULES)
outcome = result["outcome"]
status = "PASS" if outcome == expected else "FAIL"
print(f"[{status}] Rule {result['id']}: {outcome} (expected {expected})")

Output:

[PASS] Rule R1: approved (expected approved)
[PASS] Rule R2: conditional (expected conditional)
[PASS] Rule R_default: rejected (expected rejected)
[PASS] Rule R_default: rejected (expected rejected)
[PASS] Rule S1: freight_priority (expected freight_priority)
[PASS] Rule S2: express (expected express)
[PASS] Rule S3: standard (expected standard)
[PASS] Rule S_default: manual_review (expected manual_review)

The evaluate_rules function is a complete, reusable decision table engine. Swapping the rules list changes the entire business logic - no code changes required.

Key Takeaways

  • Every function with branching logic contains a decision tree - designing it deliberately prevents silent failures and maintainability problems
  • Build a decision table before writing nested if code to make all condition combinations explicit and catch missing cases early
  • Always end a decision tree with an explicit else: raise ValueError(...) to convert the "missing else" silent failure into a loud error
  • Correctness ordering (specific before general) is non-negotiable; performance ordering (cheap before expensive) applies within logically interchangeable condition groups
  • Separate condition logic (pure classifier function) from action logic (side-effectful function) so each can be tested independently
  • Data-driven decision tables store rules as JSON and use an interpreter function, enabling runtime configurability without code deployments
  • Use itertools.product over boundary values to achieve combinatorial test coverage for all execution paths
  • Boolean blindness - conditions that return True/False without explaining which criterion failed - is solved by returning structured result objects with reason fields
  • A handcrafted decision tree is appropriate for stable, auditable business rules; an ML decision tree is appropriate when rules emerge from data patterns too complex for humans to enumerate
© 2026 EngineersOfAI. All rights reserved.