Parse and evaluate a policy expressionLESSON 2.38 · 38 OF 43 IN CHAPTER
PART A / Coding problems and trade-offs
Step 64 of 252
LESSON 2.38 · 38 OF 43 IN CHAPTERTry it, then open the solution

Parse and evaluate a policy expression

THE PROBLEM

“An admin tool stores small policies such as role == "admin" OR active == TRUE AND tier == 2. Evaluate them against a record. Parentheses must work, AND must bind more tightly than OR, and missing fields must not accidentally grant access. Reject malformed policy text even when its first branch would already be true.”

Write this:

def evaluate(expression, record):
    ...

def lex(expression):
    ...

Constructed practice question. Prerequisites: stack state and recursive structure. A lexer converts characters to tokens. A parser assigns grammatical structure. An evaluator computes that structure's meaning; combining these jobs carelessly hides syntax errors.

Contract Required behavior
Input Policy string and field mapping; field names match [A-Za-z_][A-Za-z0-9_.]*
Literals JSON double-quoted strings, integers, uppercase TRUE/FALSE
Operators ==, !=, AND, OR, parentheses; AND precedes OR
Missing/types Missing field or mismatched scalar type makes either comparison false
Failure Malformed input raises ValueError with location; no host-language evaluation
Limits/scope At most 65,536 characters, 4,096 tokens, 100 nested parentheses; no calls/NOT
Optional refresher · the underlying tool

An expression evaluator has two jobs: turn characters into tokens, then interpret tokens according to precedence and parentheses. Do not call Python eval on a user-supplied policy:

expression = 'role == "admin" AND active == TRUE'
tokens = ["role", "==", "admin", "AND", "active", "==", True]
# Illustrative decoded token values; the real lexer also records positions.

Ask whether AND binds more tightly than OR, how missing fields behave, and which operators are permitted. A parser should reject a trailing unexpected token rather than silently accept part of the input.

A design choice worth saying aloud

Give lexing, parsing, and evaluation separate responsibilities. A token includes its location for useful errors; the parser must consume the entire input before the evaluator can short-circuit safely. Restrict field lookup to the supplied mapping—Python eval or attribute traversal would grant behavior the policy language never promised.

What the interviewer expects

The interviewer gives you the scenario and the contract above. Explain what a successful call returns, walk through one example below, and name what your state means before choosing a data structure.

Done means: Return the exact Boolean value defined by the fully parsed policy, or raise ValueError for malformed or over-budget input.

Now predict each output before looking at the reference; invalid input should leave any existing state unchanged unless the contract says otherwise.

Test-case scenarios to settle before coding

01 · Precedence

Input / starting state
a==TRUE OR b==TRUE AND c==TRUE with T,F,F
Expected result
True

What it is testing: AND binds before OR.

02 · Parentheses

Input / starting state
(a==TRUE OR b==TRUE) AND c==TRUE
Expected result
False

What it is testing: Grouping changes authority.

03 · Missing

Input / starting state
missing != "admin"
Expected result
False

What it is testing: Absence cannot accidentally grant access.

04 · Malformed right branch

Input / starting state
a==TRUE OR ???
Expected result
ValueError

What it is testing: Short-circuit evaluation must not skip parsing.

05 · Quoted content

Input / starting state
name == "Ada \"OR\" Lovelace", record {"name": 'Ada "OR" Lovelace'}
Expected result
True

What it is testing: Escaped quotes and OR inside the string are not operators.

06 · Type boundary

Input / starting state
tier != 2, record {"tier": "2"}
Expected result
False

What it is testing: Mismatched scalar types make both comparisons false.

07 · Token limit

Input / starting state
'a==TRUE OR ' * 2049 + 'a==TRUE', record {"a": True}
Expected result
ValueError

What it is testing: Exceeds the 4,096-token admission limit before evaluation.

For each case, show which branch or state change produces that result.

Given {a:True,b:False,c:False}, a == TRUE OR b == TRUE AND c == TRUE is true; (a == TRUE OR b == TRUE) AND c == TRUE is false. missing != "admin" is false. a == TRUE OR ??? raises, despite the true left branch. Dotted field names are literal mapping keys, not property traversal. Clarify missing-field behavior first.

Diagram: Test-case scenarios to settle before coding

Try independently. First write the grammar and tokenize a quoted string containing an escaped quote; do not start by splitting text on spaces or AND.

Solution, grammar, and evolving semantics

Space splitting fails on name == "Ada Lovelace"; splitting on OR fails when a string contains those letters. Left-to-right evaluation gives the wrong precedence. Passing untrusted text to Python eval gives the host language capabilities absent from this contract. The baseline should instead be a deliberately small language.

expression  := conjunction (OR conjunction)*
conjunction := factor (AND factor)*
factor      := '(' expression ')' | IDENT ('==' | '!=') literal
literal     := STRING | INTEGER | TRUE | FALSE

The lexer advances from the current offset, refusing unmatched characters and decoding strings with JSON's escape rules. Recursive descent follows the grammar: expression consumes OR groups, conjunction consumes AND groups, and factor handles parentheses or one comparison. Requiring EOF rejects trailing junk. Parse the entire policy into an abstract syntax tree before accessing the record.

each parser routine consumes exactly its grammatical production and returns a subtree with the declared precedence. The evaluator then uses an explicit stack and short-circuits AND/OR; skipping a right subtree skips record access, never syntax validation. Strict scalar types prevent Python's True == 1 from leaking into policy semantics. Missing != is false, not accidental permission.

Phase Example evidence Why it matters
Lex ID(a), EQ, TRUE, OR, ID(b), EQ, TRUE, AND… Quoted content remains one token
Parse OR(a, AND(b,c)) AND grouped first
Evaluate a=true; OR skips right subtree Short circuit after complete validation

Watch the same expression's persistent token, syntax-tree, and evaluation states in the parser trace, or use the . Pause before evaluation and predict which fully parsed branch will be skipped.

For C characters and T tokens, scanning/parsing/tree traversal use O(C+T) ordinary token work and O(C+T) retained text/tree/stack space. Integer conversion/comparison also depends on literal bit length; resource limits bound admitted work, and Python may reject extremely long integer literals. No claim of constant-time arbitrary precision arithmetic is intended. Long operator chains use iterative evaluation; parenthesis recursion is explicitly bounded.

Follow-up 1 — parentheses change authority. Predict the regrouped example's result before viewing its tree. The final c check now applies even when a is true.

Diagram: Test-case scenarios to settle before coding

Follow-up 2 — add NOT or an unknown result. Adding NOT requires a new precedence production above comparisons. If missing becomes “unknown,” define three-valued truth tables: simply negating today's missing=false would turn absent attributes into true. Agree on authorization semantics and compatibility before extending syntax.

Senior depth tests malformed right branches, quoting, precedence, missing/type rules, and short-circuit accesses independently. Lead depth adds grammar versions, resource budgets, and policy migration; this interpreter is a teaching language, not a complete authorization system.

Reference: solution.py (download file, source below); tests include malformed syntax, escaped strings, guarded record access, long chains, and depth/token limits.

solution.py · solution.py
"""A small explicit policy language; never use Python eval on policy input."""
from collections.abc import Mapping
from dataclasses import dataclass
import json
import re

MAX_CHARS = 65536
MAX_TOKENS = 4096
MAX_DEPTH = 100


@dataclass(frozen=True)
class Token:
    kind: str
    value: object
    position: int


_TOKEN = re.compile(
    r'(?P<SPACE>\s+)|(?P<STRING>"(?:\\.|[^"\\\x00-\x1f])*")'
    r'|(?P<NUMBER>-?(?:0|[1-9][0-9]*))|(?P<EQ>==)|(?P<NE>!=)'
    r'|(?P<LPAREN>\()|(?P<RPAREN>\))|(?P<ID>[A-Za-z_][A-Za-z0-9_.]*)'
)


def lex(expression):
    if not isinstance(expression, str) or len(expression) > MAX_CHARS:
        raise ValueError("policy must be a string of at most 65536 characters")
    tokens, position = [], 0
    while position < len(expression):
        match = _TOKEN.match(expression, position)
        if match is None:
            raise ValueError(f"invalid token at position {position}")
        kind, value = match.lastgroup, match.group()
        if kind != 'SPACE':
            if kind == 'ID' and value in ('AND', 'OR', 'TRUE', 'FALSE'):
                kind = value
            if kind == 'STRING':
                try:
                    value = json.loads(value)
                except ValueError as exc:
                    raise ValueError(f"invalid string at position {position}") from exc
            elif kind == 'NUMBER':
                value = int(value)
            elif kind in ('TRUE', 'FALSE'):
                value = kind == 'TRUE'
            tokens.append(Token(kind, value, position))
            if len(tokens) > MAX_TOKENS:
                raise ValueError("policy exceeds 4096 tokens")
        position = match.end()
    tokens.append(Token('EOF', None, position))
    return tokens


class Parser:
    def __init__(self, tokens):
        self.tokens, self.position = tokens, 0

    def take(self, kind):
        token = self.tokens[self.position]
        if token.kind != kind:
            raise ValueError(f"expected {kind} at position {token.position}")
        self.position += 1
        return token.value

    def parse(self):
        node = self.expression(0)
        self.take('EOF')
        return node

    def expression(self, depth):
        node = self.conjunction(depth)
        while self.tokens[self.position].kind == 'OR':
            self.take('OR')
            node = ('OR', node, self.conjunction(depth))
        return node

    def conjunction(self, depth):
        node = self.factor(depth)
        while self.tokens[self.position].kind == 'AND':
            self.take('AND')
            node = ('AND', node, self.factor(depth))
        return node

    def factor(self, depth):
        if self.tokens[self.position].kind == 'LPAREN':
            if depth >= MAX_DEPTH:
                raise ValueError("policy exceeds 100 nested parentheses")
            self.take('LPAREN')
            node = self.expression(depth + 1)
            self.take('RPAREN')
            return node
        field = self.take('ID')
        kind = self.tokens[self.position].kind
        if kind not in ('EQ', 'NE'):
            raise ValueError(f"expected comparison at position {self.tokens[self.position].position}")
        self.take(kind)
        literal_kind = self.tokens[self.position].kind
        if literal_kind not in ('STRING', 'NUMBER', 'TRUE', 'FALSE'):
            raise ValueError(f"expected literal at position {self.tokens[self.position].position}")
        return ('PRED', field, kind, self.take(literal_kind))


def evaluate(expression, record):
    if not isinstance(record, Mapping):
        raise ValueError("record must be a mapping")
    tree = Parser(lex(expression)).parse()
    # Explicit traversal avoids recursion on long left-associated AND/OR chains.
    pending, results = [(tree, False)], []
    while pending:
        node, left_done = pending.pop()
        if node[0] == 'PRED':
            _, field, operator, expected = node
            if field not in record:
                results.append(False)
            else:
                actual = record[field]
                equal = type(actual) is type(expected) and actual == expected
                # A mismatched type fails both comparison operators.
                results.append(equal if operator == 'EQ' else
                               type(actual) is type(expected) and not equal)
        elif not left_done:
            pending.append((node, True))
            pending.append((node[1], False))
        else:
            left = results.pop()
            if (node[0] == 'AND' and not left) or (node[0] == 'OR' and left):
                results.append(left)
            else:
                pending.append((node[2], False))
    return results[0]
python -m unittest discover -s curriculum/01-code/02-data-structures-algorithms/problems/39-policy-expression-evaluator -p 'test_*.py'