Files
mono/artdag/sexp/evaluator.py
giles cc2dcbddd4 Squashed 'core/' content from commit 4957443
git-subtree-dir: core
git-subtree-split: 4957443184ae0eb6323635a90a19acffb3e01d07
2026-02-24 23:09:39 +00:00

870 lines
26 KiB
Python

"""
Expression evaluator for S-expression DSL.
Supports:
- Arithmetic: +, -, *, /, mod, sqrt, pow, abs, floor, ceil, round, min, max, clamp
- Comparison: =, <, >, <=, >=
- Logic: and, or, not
- Predicates: odd?, even?, zero?, nil?
- Conditionals: if, cond, case
- Data: list, dict/map construction, get
- Lambda calls
"""
from typing import Any, Dict, List, Callable
from .parser import Symbol, Keyword, Lambda, Binding
class EvalError(Exception):
"""Error during expression evaluation."""
pass
# Built-in functions
BUILTINS: Dict[str, Callable] = {}
def builtin(name: str):
"""Decorator to register a builtin function."""
def decorator(fn):
BUILTINS[name] = fn
return fn
return decorator
@builtin("+")
def add(*args):
return sum(args)
@builtin("-")
def sub(a, b=None):
if b is None:
return -a
return a - b
@builtin("*")
def mul(*args):
result = 1
for a in args:
result *= a
return result
@builtin("/")
def div(a, b):
return a / b
@builtin("mod")
def mod(a, b):
return a % b
@builtin("sqrt")
def sqrt(x):
return x ** 0.5
@builtin("pow")
def power(x, n):
return x ** n
@builtin("abs")
def absolute(x):
return abs(x)
@builtin("floor")
def floor_fn(x):
import math
return math.floor(x)
@builtin("ceil")
def ceil_fn(x):
import math
return math.ceil(x)
@builtin("round")
def round_fn(x, ndigits=0):
return round(x, int(ndigits))
@builtin("min")
def min_fn(*args):
if len(args) == 1 and isinstance(args[0], (list, tuple)):
return min(args[0])
return min(args)
@builtin("max")
def max_fn(*args):
if len(args) == 1 and isinstance(args[0], (list, tuple)):
return max(args[0])
return max(args)
@builtin("clamp")
def clamp(x, lo, hi):
return max(lo, min(hi, x))
@builtin("=")
def eq(a, b):
return a == b
@builtin("<")
def lt(a, b):
return a < b
@builtin(">")
def gt(a, b):
return a > b
@builtin("<=")
def lte(a, b):
return a <= b
@builtin(">=")
def gte(a, b):
return a >= b
@builtin("odd?")
def is_odd(n):
return n % 2 == 1
@builtin("even?")
def is_even(n):
return n % 2 == 0
@builtin("zero?")
def is_zero(n):
return n == 0
@builtin("nil?")
def is_nil(x):
return x is None
@builtin("not")
def not_fn(x):
return not x
@builtin("inc")
def inc(n):
return n + 1
@builtin("dec")
def dec(n):
return n - 1
@builtin("list")
def make_list(*args):
return list(args)
@builtin("assert")
def assert_true(condition, message="Assertion failed"):
if not condition:
raise RuntimeError(f"Assertion error: {message}")
return True
@builtin("get")
def get(coll, key, default=None):
if isinstance(coll, dict):
# Try the key directly first
result = coll.get(key, None)
if result is not None:
return result
# If key is a Keyword, also try its string name (for Python dicts with string keys)
if isinstance(key, Keyword):
result = coll.get(key.name, None)
if result is not None:
return result
# Return the default
return default
elif isinstance(coll, list):
return coll[key] if 0 <= key < len(coll) else default
else:
raise EvalError(f"get: expected dict or list, got {type(coll).__name__}: {str(coll)[:100]}")
@builtin("dict?")
def is_dict(x):
return isinstance(x, dict)
@builtin("list?")
def is_list(x):
return isinstance(x, list)
@builtin("nil?")
def is_nil(x):
return x is None
@builtin("number?")
def is_number(x):
return isinstance(x, (int, float))
@builtin("string?")
def is_string(x):
return isinstance(x, str)
@builtin("len")
def length(coll):
return len(coll)
@builtin("first")
def first(coll):
return coll[0] if coll else None
@builtin("last")
def last(coll):
return coll[-1] if coll else None
@builtin("chunk-every")
def chunk_every(coll, n):
"""Split collection into chunks of n elements."""
n = int(n)
return [coll[i:i+n] for i in range(0, len(coll), n)]
@builtin("rest")
def rest(coll):
return coll[1:] if coll else []
@builtin("nth")
def nth(coll, n):
return coll[n] if 0 <= n < len(coll) else None
@builtin("concat")
def concat(*colls):
"""Concatenate multiple lists/sequences."""
result = []
for c in colls:
if c is not None:
result.extend(c)
return result
@builtin("cons")
def cons(x, coll):
"""Prepend x to collection."""
return [x] + list(coll) if coll else [x]
@builtin("append")
def append(coll, x):
"""Append x to collection."""
return list(coll) + [x] if coll else [x]
@builtin("range")
def make_range(start, end, step=1):
"""Create a range of numbers."""
return list(range(int(start), int(end), int(step)))
@builtin("zip-pairs")
def zip_pairs(coll):
"""Zip consecutive pairs: [a,b,c,d] -> [[a,b],[b,c],[c,d]]."""
if not coll or len(coll) < 2:
return []
return [[coll[i], coll[i+1]] for i in range(len(coll)-1)]
@builtin("dict")
def make_dict(*pairs):
"""Create dict from key-value pairs: (dict :a 1 :b 2)."""
result = {}
i = 0
while i < len(pairs) - 1:
key = pairs[i]
if isinstance(key, Keyword):
key = key.name
result[key] = pairs[i + 1]
i += 2
return result
@builtin("keys")
def keys(d):
"""Get the keys of a dict as a list."""
if not isinstance(d, dict):
raise EvalError(f"keys: expected dict, got {type(d).__name__}")
return list(d.keys())
@builtin("vals")
def vals(d):
"""Get the values of a dict as a list."""
if not isinstance(d, dict):
raise EvalError(f"vals: expected dict, got {type(d).__name__}")
return list(d.values())
@builtin("merge")
def merge(*dicts):
"""Merge multiple dicts, later dicts override earlier."""
result = {}
for d in dicts:
if d is not None:
if not isinstance(d, dict):
raise EvalError(f"merge: expected dict, got {type(d).__name__}")
result.update(d)
return result
@builtin("assoc")
def assoc(d, *pairs):
"""Associate keys with values in a dict: (assoc d :a 1 :b 2)."""
if d is None:
result = {}
elif isinstance(d, dict):
result = dict(d)
else:
raise EvalError(f"assoc: expected dict or nil, got {type(d).__name__}")
i = 0
while i < len(pairs) - 1:
key = pairs[i]
if isinstance(key, Keyword):
key = key.name
result[key] = pairs[i + 1]
i += 2
return result
@builtin("dissoc")
def dissoc(d, *keys_to_remove):
"""Remove keys from a dict: (dissoc d :a :b)."""
if d is None:
return {}
if not isinstance(d, dict):
raise EvalError(f"dissoc: expected dict or nil, got {type(d).__name__}")
result = dict(d)
for key in keys_to_remove:
if isinstance(key, Keyword):
key = key.name
result.pop(key, None)
return result
@builtin("into")
def into(target, coll):
"""Convert a collection into another collection type.
(into [] {:a 1 :b 2}) -> [["a" 1] ["b" 2]]
(into {} [[:a 1] [:b 2]]) -> {"a": 1, "b": 2}
(into [] [1 2 3]) -> [1 2 3]
"""
if isinstance(target, list):
if isinstance(coll, dict):
return [[k, v] for k, v in coll.items()]
elif isinstance(coll, (list, tuple)):
return list(coll)
else:
raise EvalError(f"into: cannot convert {type(coll).__name__} into list")
elif isinstance(target, dict):
if isinstance(coll, dict):
return dict(coll)
elif isinstance(coll, (list, tuple)):
result = {}
for item in coll:
if isinstance(item, (list, tuple)) and len(item) >= 2:
key = item[0]
if isinstance(key, Keyword):
key = key.name
result[key] = item[1]
else:
raise EvalError(f"into: expected [key value] pairs, got {item}")
return result
else:
raise EvalError(f"into: cannot convert {type(coll).__name__} into dict")
else:
raise EvalError(f"into: unsupported target type {type(target).__name__}")
@builtin("filter")
def filter_fn(pred, coll):
"""Filter collection by predicate. Pred must be a lambda."""
if not isinstance(pred, Lambda):
raise EvalError(f"filter: expected lambda as predicate, got {type(pred).__name__}")
result = []
for item in coll:
# Evaluate predicate with item
local_env = {}
if pred.closure:
local_env.update(pred.closure)
local_env[pred.params[0]] = item
# Inline evaluation of pred.body
from . import evaluator
if evaluator.evaluate(pred.body, local_env):
result.append(item)
return result
@builtin("some")
def some(pred, coll):
"""Return first truthy value of (pred item) for items in coll, or nil."""
if not isinstance(pred, Lambda):
raise EvalError(f"some: expected lambda as predicate, got {type(pred).__name__}")
for item in coll:
local_env = {}
if pred.closure:
local_env.update(pred.closure)
local_env[pred.params[0]] = item
from . import evaluator
result = evaluator.evaluate(pred.body, local_env)
if result:
return result
return None
@builtin("every?")
def every(pred, coll):
"""Return true if (pred item) is truthy for all items in coll."""
if not isinstance(pred, Lambda):
raise EvalError(f"every?: expected lambda as predicate, got {type(pred).__name__}")
for item in coll:
local_env = {}
if pred.closure:
local_env.update(pred.closure)
local_env[pred.params[0]] = item
from . import evaluator
if not evaluator.evaluate(pred.body, local_env):
return False
return True
@builtin("empty?")
def is_empty(coll):
"""Return true if collection is empty."""
if coll is None:
return True
return len(coll) == 0
@builtin("contains?")
def contains(coll, key):
"""Check if collection contains key (for dicts) or element (for lists)."""
if isinstance(coll, dict):
if isinstance(key, Keyword):
key = key.name
return key in coll
elif isinstance(coll, (list, tuple)):
return key in coll
return False
def evaluate(expr: Any, env: Dict[str, Any] = None) -> Any:
"""
Evaluate an S-expression in the given environment.
Args:
expr: The expression to evaluate
env: Variable bindings (name -> value)
Returns:
The result of evaluation
"""
if env is None:
env = {}
# Literals
if isinstance(expr, (int, float, str, bool)) or expr is None:
return expr
# Symbol - variable lookup
if isinstance(expr, Symbol):
name = expr.name
if name in env:
return env[name]
if name in BUILTINS:
return BUILTINS[name]
if name == "true":
return True
if name == "false":
return False
if name == "nil":
return None
raise EvalError(f"Undefined symbol: {name}")
# Keyword - return as-is (used as map keys)
if isinstance(expr, Keyword):
return expr.name
# Lambda - return as-is (it's a value)
if isinstance(expr, Lambda):
return expr
# Binding - return as-is (resolved at execution time)
if isinstance(expr, Binding):
return expr
# Dict literal
if isinstance(expr, dict):
return {k: evaluate(v, env) for k, v in expr.items()}
# List - function call or special form
if isinstance(expr, list):
if not expr:
return []
head = expr[0]
# If head is a string/number/etc (not Symbol), treat as data list
if not isinstance(head, (Symbol, Lambda, list)):
return [evaluate(x, env) for x in expr]
# Special forms
if isinstance(head, Symbol):
name = head.name
# if - conditional
if name == "if":
if len(expr) < 3:
raise EvalError("if requires condition and then-branch")
cond_result = evaluate(expr[1], env)
if cond_result:
return evaluate(expr[2], env)
elif len(expr) > 3:
return evaluate(expr[3], env)
return None
# cond - multi-way conditional
# Supports both Clojure style: (cond test1 result1 test2 result2 :else default)
# and Scheme style: (cond (test1 result1) (test2 result2) (else default))
if name == "cond":
clauses = expr[1:]
# Check if Clojure style (flat list) or Scheme style (nested pairs)
# Scheme style: first clause is (test result) - exactly 2 elements
# Clojure style: first clause is a test expression like (= x 0) - 3+ elements
first_is_scheme_clause = (
clauses and
isinstance(clauses[0], list) and
len(clauses[0]) == 2 and
not (isinstance(clauses[0][0], Symbol) and clauses[0][0].name in ('=', '<', '>', '<=', '>=', '!=', 'not=', 'and', 'or'))
)
if first_is_scheme_clause:
# Scheme style: ((test result) ...)
for clause in clauses:
if not isinstance(clause, list) or len(clause) < 2:
raise EvalError("cond clause must be (test result)")
test = clause[0]
# Check for else/default
if isinstance(test, Symbol) and test.name in ("else", ":else"):
return evaluate(clause[1], env)
if isinstance(test, Keyword) and test.name == "else":
return evaluate(clause[1], env)
if evaluate(test, env):
return evaluate(clause[1], env)
else:
# Clojure style: test1 result1 test2 result2 ...
i = 0
while i < len(clauses) - 1:
test = clauses[i]
result = clauses[i + 1]
# Check for :else
if isinstance(test, Keyword) and test.name == "else":
return evaluate(result, env)
if isinstance(test, Symbol) and test.name == ":else":
return evaluate(result, env)
if evaluate(test, env):
return evaluate(result, env)
i += 2
return None
# case - switch on value
# (case expr val1 result1 val2 result2 :else default)
if name == "case":
if len(expr) < 2:
raise EvalError("case requires expression to match")
match_val = evaluate(expr[1], env)
clauses = expr[2:]
i = 0
while i < len(clauses) - 1:
test = clauses[i]
result = clauses[i + 1]
# Check for :else / else
if isinstance(test, Keyword) and test.name == "else":
return evaluate(result, env)
if isinstance(test, Symbol) and test.name in (":else", "else"):
return evaluate(result, env)
# Evaluate test value and compare
test_val = evaluate(test, env)
if match_val == test_val:
return evaluate(result, env)
i += 2
return None
# and - short-circuit
if name == "and":
result = True
for arg in expr[1:]:
result = evaluate(arg, env)
if not result:
return result
return result
# or - short-circuit
if name == "or":
result = False
for arg in expr[1:]:
result = evaluate(arg, env)
if result:
return result
return result
# let and let* - local bindings (both bind sequentially in this impl)
if name in ("let", "let*"):
if len(expr) < 3:
raise EvalError(f"{name} requires bindings and body")
bindings = expr[1]
local_env = dict(env)
if isinstance(bindings, list):
# Check if it's ((name value) ...) style (Lisp let* style)
if bindings and isinstance(bindings[0], list):
for binding in bindings:
if len(binding) != 2:
raise EvalError(f"{name} binding must be (name value)")
var_name = binding[0]
if isinstance(var_name, Symbol):
var_name = var_name.name
value = evaluate(binding[1], local_env)
local_env[var_name] = value
# Vector-style [name value ...]
elif len(bindings) % 2 == 0:
for i in range(0, len(bindings), 2):
var_name = bindings[i]
if isinstance(var_name, Symbol):
var_name = var_name.name
value = evaluate(bindings[i + 1], local_env)
local_env[var_name] = value
else:
raise EvalError(f"{name} bindings must be [name value ...] or ((name value) ...)")
else:
raise EvalError(f"{name} bindings must be a list")
return evaluate(expr[2], local_env)
# lambda / fn - create function with closure
if name in ("lambda", "fn"):
if len(expr) < 3:
raise EvalError("lambda requires params and body")
params = expr[1]
if not isinstance(params, list):
raise EvalError("lambda params must be a list")
param_names = []
for p in params:
if isinstance(p, Symbol):
param_names.append(p.name)
elif isinstance(p, str):
param_names.append(p)
else:
raise EvalError(f"Invalid param: {p}")
# Capture current environment as closure
return Lambda(param_names, expr[2], dict(env))
# quote - return unevaluated
if name == "quote":
return expr[1] if len(expr) > 1 else None
# bind - create binding to analysis data
# (bind analysis-var)
# (bind analysis-var :range [0.3 1.0])
# (bind analysis-var :range [0 100] :transform sqrt)
if name == "bind":
if len(expr) < 2:
raise EvalError("bind requires analysis reference")
analysis_ref = expr[1]
if isinstance(analysis_ref, Symbol):
symbol_name = analysis_ref.name
# Look up the symbol in environment
if symbol_name in env:
resolved = env[symbol_name]
# If resolved is actual analysis data (dict with times/values or
# S-expression list with Keywords), keep the symbol name as reference
# for later lookup at execution time
if isinstance(resolved, dict) and ("times" in resolved or "values" in resolved):
analysis_ref = symbol_name # Use name as reference, not the data
elif isinstance(resolved, list) and any(isinstance(x, Keyword) for x in resolved):
# Parsed S-expression analysis data ([:times [...] :duration ...])
analysis_ref = symbol_name
else:
analysis_ref = resolved
else:
raise EvalError(f"bind: undefined symbol '{symbol_name}' - must reference analysis data")
# Parse optional :range [min max] and :transform
range_min, range_max = 0.0, 1.0
transform = None
i = 2
while i < len(expr):
if isinstance(expr[i], Keyword):
kw = expr[i].name
if kw == "range" and i + 1 < len(expr):
range_val = evaluate(expr[i + 1], env) # Evaluate to get actual value
if isinstance(range_val, list) and len(range_val) >= 2:
range_min = float(range_val[0])
range_max = float(range_val[1])
i += 2
elif kw == "transform" and i + 1 < len(expr):
t = expr[i + 1]
if isinstance(t, Symbol):
transform = t.name
elif isinstance(t, str):
transform = t
i += 2
else:
i += 1
else:
i += 1
return Binding(analysis_ref, range_min=range_min, range_max=range_max, transform=transform)
# Vector literal [a b c]
if name == "vec" or name == "vector":
return [evaluate(e, env) for e in expr[1:]]
# map - (map fn coll)
if name == "map":
if len(expr) != 3:
raise EvalError("map requires fn and collection")
fn = evaluate(expr[1], env)
coll = evaluate(expr[2], env)
if not isinstance(fn, Lambda):
raise EvalError(f"map requires lambda, got {type(fn)}")
result = []
for item in coll:
local_env = {}
if fn.closure:
local_env.update(fn.closure)
local_env.update(env)
local_env[fn.params[0]] = item
result.append(evaluate(fn.body, local_env))
return result
# map-indexed - (map-indexed fn coll)
if name == "map-indexed":
if len(expr) != 3:
raise EvalError("map-indexed requires fn and collection")
fn = evaluate(expr[1], env)
coll = evaluate(expr[2], env)
if not isinstance(fn, Lambda):
raise EvalError(f"map-indexed requires lambda, got {type(fn)}")
if len(fn.params) < 2:
raise EvalError("map-indexed lambda needs (i item) params")
result = []
for i, item in enumerate(coll):
local_env = {}
if fn.closure:
local_env.update(fn.closure)
local_env.update(env)
local_env[fn.params[0]] = i
local_env[fn.params[1]] = item
result.append(evaluate(fn.body, local_env))
return result
# reduce - (reduce fn init coll)
if name == "reduce":
if len(expr) != 4:
raise EvalError("reduce requires fn, init, and collection")
fn = evaluate(expr[1], env)
acc = evaluate(expr[2], env)
coll = evaluate(expr[3], env)
if not isinstance(fn, Lambda):
raise EvalError(f"reduce requires lambda, got {type(fn)}")
if len(fn.params) < 2:
raise EvalError("reduce lambda needs (acc item) params")
for item in coll:
local_env = {}
if fn.closure:
local_env.update(fn.closure)
local_env.update(env)
local_env[fn.params[0]] = acc
local_env[fn.params[1]] = item
acc = evaluate(fn.body, local_env)
return acc
# for-each - (for-each fn coll) - iterate with side effects
if name == "for-each":
if len(expr) != 3:
raise EvalError("for-each requires fn and collection")
fn = evaluate(expr[1], env)
coll = evaluate(expr[2], env)
if not isinstance(fn, Lambda):
raise EvalError(f"for-each requires lambda, got {type(fn)}")
for item in coll:
local_env = {}
if fn.closure:
local_env.update(fn.closure)
local_env.update(env)
local_env[fn.params[0]] = item
evaluate(fn.body, local_env)
return None
# Function call
fn = evaluate(head, env)
args = [evaluate(arg, env) for arg in expr[1:]]
# Call builtin
if callable(fn):
return fn(*args)
# Call lambda
if isinstance(fn, Lambda):
if len(args) != len(fn.params):
raise EvalError(f"Lambda expects {len(fn.params)} args, got {len(args)}")
# Start with closure (captured env), then overlay calling env, then params
local_env = {}
if fn.closure:
local_env.update(fn.closure)
local_env.update(env)
for name, value in zip(fn.params, args):
local_env[name] = value
return evaluate(fn.body, local_env)
raise EvalError(f"Not callable: {fn}")
raise EvalError(f"Cannot evaluate: {expr!r}")
def make_env(**kwargs) -> Dict[str, Any]:
"""Create an environment with initial bindings."""
return dict(kwargs)