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
ifblocks - 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.productto cover all decision branches - Six interview questions covering the full engineering depth of this topic
Prerequisites
- Python
if/elif/elseand guard clauses (Lessons 01 and 04 of this module) - Python dictionaries and lists (Module 2)
- Basic function design (Module 3)
- Python
dataclassesfor 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 Score | Employed | Income | Debt Ratio | Outcome | Rule |
|---|---|---|---|---|---|
| >= 700 | Yes | >= 50k | < 0.4 | Approved | R1 |
| >= 700 | Yes | >= 50k | >= 0.4 | Conditional | R2 |
| >= 700 | Yes | < 50k | < 0.5 | Conditional | R3 |
| >= 700 | Yes | < 50k | >= 0.5 | Rejected | R4 |
| >= 700 | No | any | any | Rejected | R5 |
| 600–699 | Yes | >= 40k | any | Conditional | R6 |
| 600–699 | Yes | < 40k | any | Rejected | R7 |
| 600–699 | No | any | any | Rejected | R8 |
| < 600 | any | any | any | Rejected | R9 |
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
| Priority | Operation | Typical cost |
|---|---|---|
| 1 | Attribute access / local variable read | nanoseconds |
| 2 | Integer or float comparison | nanoseconds |
| 3 | String comparison | microseconds |
| 4 | Dictionary lookup | microseconds |
| 5 | List search / membership test | depends on length |
| 6 | Regex match | microseconds to milliseconds |
| 7 | File I/O or network call | milliseconds 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 when | Use an ML Decision Tree when |
|---|---|
| Business rules are explicitly known | Rules emerge from data patterns |
| Rules are stable and documented | Rules are too complex for humans to specify |
| Auditability and explainability required | You have labelled training data available |
| Regulatory compliance requires traceability | Feature interactions are non-obvious |
| Simple enough to be understood and reviewed | Dataset 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
| Concept | What it solves | Key technique |
|---|---|---|
| Decision table | Missing cases, undocumented rules | Build M-condition × N-outcome table before coding |
| Guard clauses | Deep nesting from validation | Early return on failure at top of function |
| Correctness ordering | Wrong branch fires for specific inputs | Most specific condition first |
| Performance ordering | Slow checks run for rejected inputs | Cheapest / most-selective check first |
| Missing else | Silent None returns | else: raise ValueError(...) |
| Boolean blindness | Can't tell why condition failed | Return structured result with reason field |
| Data-driven rules | Rules change without code deployment | Store rules as JSON, use interpreter function |
| Completeness check | Not all inputs handled | Enum input types + case _: raise |
| Combinatorial testing | Not all branches exercised | itertools.product on boundary values |
| Separation of concerns | Condition and action code hard to test | classify() (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 >= 700is True,verifiedis False, so the firstifin that branch fails - returns"silver"from the barereturnafter the innerif.classify(750, True, 0):score >= 700is True,verifiedis True, returns"gold".balanceis never checked.classify(600, False, 2000):score >= 700is False,score >= 500is True,balance > 1000is True (2000 > 1000), returns"silver".verifiedis never checked.classify(400, True, 9999):score >= 700is False,score >= 500is False, returns"rejected". Neitherverifiednorbalanceis 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:
- The
elif annual_spend > 5_000:condition fires before the VIP 20% and 15-year 15% rules, making them unreachable. - No
elseclause -calculate_discount(1, 1000, False)returnsNone.
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:
- Load rules from a JSON string (format: list of
{id, conditions, outcome}) - Each condition has
{field, op, value}whereopis one of>=,<=,>,<,==,!=,in - A rule matches if ALL its conditions match (AND logic)
- Return the outcome of the first matching rule
- Raise a
RuntimeErrorif no rule matches (incomplete table) - Support a special
"in"operator that checks iffield_value in value(wherevalueis 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
ifcode 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.productover 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
