The coding question
You're given a list of transactions, each with attributes like amount and country, and a list of deny rules like 'amount > 1000'. Decide whether each transaction is accepted or denied. Then support rules combined with AND and OR, and then nested rules with parentheses.
Ask a few questions first
| Ask | What we'll assume |
|---|---|
| What does a rule look like? | field, operator, value, with spaces between them. Operators are >, <, >=, <=, == and !=. Values are numbers or words, with optional quotes. |
| When is a transaction denied? | If any rule matches it. We'll also say which rule did it, since that's handy for debugging. |
| What if a transaction doesn't have the field? | The condition is false. A missing country can't match 'country == RU'. |
| Does AND bind tighter than OR? | No. The question says no precedence, so we read left to right: 'a OR b AND c' means '(a OR b) AND c'. Always ask this one. |
| What about a broken rule? | Raise an error when the rules are loaded, not halfway through checking transactions. |
A couple of examples
| Input | Output |
|---|---|
| t1 = amount 500, US. t2 = amount 1500, US. t3 = amount 200, RU. Rules: ['amount > 1000'] | [('ACCEPT', None), ('DENY', 'amount > 1000'), ('ACCEPT', None)] |
| Same transactions. Rules: ['amount > 100 AND country == RU'] | [('ACCEPT', None), ('ACCEPT', None), ('DENY', 'amount > 100 AND country == RU')] |
| Same transactions. Rules: ['country == US AND (amount > 1000 OR amount < 100)'] | [('ACCEPT', None), ('DENY', 'country == US AND (amount > 1000 OR amount < 100)'), ('ACCEPT', None)] |
Start with brute force
For part 1, split each rule on spaces and compare, for every transaction and every rule. That's fine. The trap is part 2: people start splitting strings on ' OR ' and ' AND ', and that falls apart the moment brackets show up in part 3. You end up rewriting everything.
Cost: O(T × R) for T transactions and R rules, which is fine. The real cost is the rewrite you'll need later.
A better way
Parse each rule once into a small tree of objects that all have a matches(transaction) method. A Condition compares one field. An And or Or holds two smaller rules. Checking a transaction is just calling matches on the top of the tree. That's object-oriented design and trees working together.
Write a tiny parser with two functions. An expression is a term, followed by any number of 'AND term' or 'OR term', built up left to right. A term is either a condition or a bracketed expression. Brackets come almost for free, because a term can call expression again.
This also maps neatly onto the three parts. Part 1 only needs Condition. Part 2 adds And, Or and the loop. Part 3 adds the bracket branch, a handful of lines.
- Split the rule text into tokens, padding brackets with spaces first.
- expression(): read a term, then while the next token is AND or OR, read another term and join them into an And or Or node.
- term(): if the next token is '(', read an expression and expect ')'. Otherwise read field, operator, value and build a Condition.
- Parse every rule once up front, so a broken rule fails straight away.
- For each transaction, find the first rule whose tree matches. If there is one, deny and report it. Otherwise accept.
The code
OPS = {
">": lambda a, b: a > b,
"<": lambda a, b: a < b,
">=": lambda a, b: a >= b,
"<=": lambda a, b: a <= b,
"==": lambda a, b: a == b,
"!=": lambda a, b: a != b,
}
# Part 1: one condition, like "amount > 1000".
class Condition:
def __init__(self, field, op, value):
self.field, self.op, self.value = field, op, value
def matches(self, txn):
if self.field not in txn:
return False
actual = txn[self.field]
if isinstance(self.value, str) != isinstance(actual, str):
return False # never compare a number with a word
return OPS[self.op](actual, self.value)
# Part 2: two rules joined by AND or OR.
class And:
def __init__(self, left, right):
self.left, self.right = left, right
def matches(self, txn):
return self.left.matches(txn) and self.right.matches(txn)
class Or:
def __init__(self, left, right):
self.left, self.right = left, right
def matches(self, txn):
return self.left.matches(txn) or self.right.matches(txn)
def tokenize(text):
# Pad brackets with spaces so "(amount > 5)" splits cleanly.
return text.replace("(", " ( ").replace(")", " ) ").split()
def parse_value(token):
if token[0] in ("'", '"'):
return token[1:-1]
try:
return float(token)
except ValueError:
return token
class Parser:
def __init__(self, text):
self.tokens = tokenize(text)
self.pos = 0
def peek(self):
return self.tokens[self.pos] if self.pos < len(self.tokens) else None
def take(self):
token = self.peek()
if token is None:
raise ValueError("rule ended early")
self.pos += 1
return token
def parse(self):
rule = self.expression()
if self.peek() is not None:
raise ValueError("unexpected " + self.peek())
return rule
# expression = term (AND|OR term)*, read left to right with no precedence
def expression(self):
rule = self.term()
while self.peek() is not None and self.peek().upper() in ("AND", "OR"):
joiner = self.take().upper()
right = self.term()
rule = And(rule, right) if joiner == "AND" else Or(rule, right)
return rule
# term = ( expression ) | field op value
def term(self):
if self.peek() == "(": # Part 3: brackets
self.take()
rule = self.expression()
if self.take() != ")":
raise ValueError("missing )")
return rule
field, op, value = self.take(), self.take(), self.take()
if op not in OPS:
raise ValueError("unknown operator " + op)
return Condition(field, op, parse_value(value))
def check(transactions, rule_texts):
# Parse every rule once, up front, so a bad rule fails fast.
rules = [(text, Parser(text).parse()) for text in rule_texts]
results = []
for txn in transactions:
hit = next((text for text, rule in rules if rule.matches(txn)), None)
results.append(("DENY", hit) if hit else ("ACCEPT", None))
return results| Cost | Why | |
|---|---|---|
| Parse | O(L) | Each token in a rule of length L is read once |
| Check | O(T × S) | Every transaction against rules with S conditions in total, in the worst case |
| Space | O(S) | One node per condition and joiner |
Test it
Walk through one of the examples out loud, then check the cases that tend to break things:
- No precedence: 'country == RU OR amount > 400 AND country == US' denies t1 and t2 and accepts t3, because it reads as '(RU OR amount > 400) AND US'.
- A missing field, like 'currency == EUR', matches nothing, so everything's accepted.
- Extra brackets, like '((amount > 1000))', work the same as none.
- Comparing a word with a number, like 'country > 5', is false instead of crashing.
- Broken rules like 'amount >', '(amount > 1' or 'amount > 1 )' raise an error when loading.
- No rules at all means everything's accepted.
Common mistakes
- Splitting strings on ' AND ' and ' OR ' in part 2. It can't handle brackets, so part 3 becomes a rewrite.
- Quietly applying normal precedence (AND before OR) when the question said there isn't any. Ask.
- Comparing values as strings, so '900' > '1000' comes out true.
- Parsing every rule again for every transaction, instead of once up front.
Follow-up questions
What if AND should bind tighter than OR?
Split expression into two levels: an or-expression is and-expressions joined by OR, and an and-expression is terms joined by AND. It's one extra small function.
How would you add NOT, or 'country in (RU, KP)'?
NOT is a new node type with one child, handled in term. 'in' is a new operator whose value is a set. The tree design means neither touches the existing classes.
What if there are thousands of rules?
Index rules by the fields they use, so you skip rules that can't match. Or merge rules sharing a condition so it's checked once.
How the time adds up
| Minutes | What you're doing |
|---|---|
| 0–5 | Ask questions and agree on the rule format |
| 5–10 | Examples, and plan the tree and parser |
| 10–20 | Part 1: Condition and check |
| 20–35 | Part 2: And, Or and the left-to-right loop |
| 35–45 | Part 3: brackets |
| 45–55 | Test the examples and edge cases |
| 55–60 | Follow-ups |
Key concepts used here
- Object-oriented designSplit a small program into classes that are easy to change.
- Trees and recursionSolve tree problems by deciding what each node returns.
We wrote this sample solution ourselves. Practice Loop is independent and isn't affiliated with or endorsed by Stripe.