Add expression-evaluator: DAGs & state machines tutorial project
Educational calculator teaching FSMs (explicit transition table tokenizer) and DAGs (recursive descent parser with AST evaluation). Includes CLI with REPL, graphviz visualization, and 61 tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
147
python/expression-evaluator/evaluator.py
Normal file
147
python/expression-evaluator/evaluator.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Part 3: DAG Evaluation -- Tree Walker
|
||||
=======================================
|
||||
Evaluating the AST bottom-up is equivalent to topological-sort
|
||||
evaluation of a DAG. We must evaluate a node's children before
|
||||
the node itself -- just like in any dependency graph.
|
||||
|
||||
For a tree, post-order traversal gives a topological ordering.
|
||||
The recursive evaluate() function naturally does this:
|
||||
1. Recursively evaluate all children (dependencies)
|
||||
2. Combine the results (compute this node's value)
|
||||
3. Return the result (make it available to the parent)
|
||||
|
||||
This is the same pattern as:
|
||||
- make: build dependencies before the target
|
||||
- pip/npm install: install dependencies before the package
|
||||
- Spreadsheet recalculation: compute referenced cells first
|
||||
"""
|
||||
|
||||
from parser import NumberNode, BinOpNode, UnaryOpNode, Node
|
||||
from tokenizer import TokenType
|
||||
|
||||
|
||||
# ---------- Errors ----------
|
||||
|
||||
class EvalError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# ---------- Evaluator ----------
|
||||
|
||||
OP_SYMBOLS = {
|
||||
TokenType.PLUS: '+',
|
||||
TokenType.MINUS: '-',
|
||||
TokenType.MULTIPLY: '*',
|
||||
TokenType.DIVIDE: '/',
|
||||
TokenType.POWER: '^',
|
||||
TokenType.UNARY_MINUS: 'neg',
|
||||
}
|
||||
|
||||
|
||||
def evaluate(node):
|
||||
"""
|
||||
Evaluate an AST by walking it bottom-up (post-order traversal).
|
||||
|
||||
This is a recursive function that mirrors the DAG structure:
|
||||
each recursive call follows a DAG edge to a child node.
|
||||
Children are evaluated before parents -- topological order.
|
||||
"""
|
||||
match node:
|
||||
case NumberNode(value=v):
|
||||
return v
|
||||
|
||||
case UnaryOpNode(op=TokenType.UNARY_MINUS, operand=child):
|
||||
return -evaluate(child)
|
||||
|
||||
case BinOpNode(op=op, left=left, right=right):
|
||||
left_val = evaluate(left)
|
||||
right_val = evaluate(right)
|
||||
match op:
|
||||
case TokenType.PLUS:
|
||||
return left_val + right_val
|
||||
case TokenType.MINUS:
|
||||
return left_val - right_val
|
||||
case TokenType.MULTIPLY:
|
||||
return left_val * right_val
|
||||
case TokenType.DIVIDE:
|
||||
if right_val == 0:
|
||||
raise EvalError("division by zero")
|
||||
return left_val / right_val
|
||||
case TokenType.POWER:
|
||||
return left_val ** right_val
|
||||
|
||||
raise EvalError(f"unknown node type: {type(node)}")
|
||||
|
||||
|
||||
def evaluate_traced(node):
|
||||
"""
|
||||
Like evaluate(), but records each step for educational display.
|
||||
Returns (result, list_of_trace_lines).
|
||||
|
||||
The trace shows the topological evaluation order -- how the DAG
|
||||
is evaluated from leaves to root. Each step shows a node being
|
||||
evaluated after all its dependencies are resolved.
|
||||
"""
|
||||
steps = []
|
||||
counter = [0] # mutable counter for step numbering
|
||||
|
||||
def _walk(node, depth):
|
||||
indent = " " * depth
|
||||
counter[0] += 1
|
||||
step = counter[0]
|
||||
|
||||
match node:
|
||||
case NumberNode(value=v):
|
||||
result = v
|
||||
display = _format_number(v)
|
||||
steps.append(f"{indent}Step {step}: {display} => {_format_number(result)}")
|
||||
return result
|
||||
|
||||
case UnaryOpNode(op=TokenType.UNARY_MINUS, operand=child):
|
||||
child_val = _walk(child, depth + 1)
|
||||
result = -child_val
|
||||
counter[0] += 1
|
||||
step = counter[0]
|
||||
steps.append(
|
||||
f"{indent}Step {step}: neg({_format_number(child_val)}) "
|
||||
f"=> {_format_number(result)}"
|
||||
)
|
||||
return result
|
||||
|
||||
case BinOpNode(op=op, left=left, right=right):
|
||||
left_val = _walk(left, depth + 1)
|
||||
right_val = _walk(right, depth + 1)
|
||||
sym = OP_SYMBOLS[op]
|
||||
match op:
|
||||
case TokenType.PLUS:
|
||||
result = left_val + right_val
|
||||
case TokenType.MINUS:
|
||||
result = left_val - right_val
|
||||
case TokenType.MULTIPLY:
|
||||
result = left_val * right_val
|
||||
case TokenType.DIVIDE:
|
||||
if right_val == 0:
|
||||
raise EvalError("division by zero")
|
||||
result = left_val / right_val
|
||||
case TokenType.POWER:
|
||||
result = left_val ** right_val
|
||||
counter[0] += 1
|
||||
step = counter[0]
|
||||
steps.append(
|
||||
f"{indent}Step {step}: {_format_number(left_val)} {sym} "
|
||||
f"{_format_number(right_val)} => {_format_number(result)}"
|
||||
)
|
||||
return result
|
||||
|
||||
raise EvalError(f"unknown node type: {type(node)}")
|
||||
|
||||
result = _walk(node, 0)
|
||||
return result, steps
|
||||
|
||||
|
||||
def _format_number(v):
|
||||
"""Display a number as integer when possible."""
|
||||
if isinstance(v, float) and v == int(v):
|
||||
return str(int(v))
|
||||
return str(v)
|
||||
Reference in New Issue
Block a user