RERUM - Rewriting Expressions via Rules Using Morphisms
Project description
RERUM
Rewriting Expressions via Rules Using Morphisms
A pattern matching and term rewriting library for symbolic computation in Python.
Installation
pip install rerum
Quick Start
from rerum import RuleEngine, E
# Create an engine with rules
engine = RuleEngine.from_dsl('''
@add-zero "x + 0 = x": (+ ?x 0) => :x
@mul-one: (* ?x 1) => :x
@mul-zero: (* ?x 0) => 0
''')
# Simplify expressions using E() to parse s-expressions
engine(E("(+ y 0)")) # => "y"
engine(E("(* x 1)")) # => "x"
engine(E("(* (+ a 0) 0)")) # => 0
# Or use raw lists
engine(["+", "y", 0]) # => "y"
DSL Syntax
Rules use a simple, readable syntax:
# Comments start with #
@rule-name: (pattern) => (skeleton)
@rule-name "Description": (pattern) => (skeleton)
@rule-name[100]: (pattern) => (skeleton) # With priority
@rule-name: (pattern) => (skeleton) when (cond) # With guard
Pattern Syntax
| Syntax | Meaning |
|---|---|
?x or ?x:expr |
Match any expression, bind to x |
?x:const |
Match constant (number) only |
?x:var |
Match variable (symbol) only |
?x:free(v) |
Match expression not containing v |
?x... |
Match zero or more remaining args (rest pattern) |
Skeleton Syntax
| Syntax | Meaning |
|---|---|
:x |
Substitute bound value of x |
:x... |
Splice list bound to x |
(! op args...) |
Compute: evaluate op with args using prelude |
Expression Builder
The E builder provides convenient expression construction:
from rerum import E
# Parse s-expression strings
expr = E("(+ x (* 2 y))") # => ["+", "x", ["*", 2, "y"]]
# Build programmatically
expr = E.op("+", "x", E.op("*", 2, "y"))
# Create variables
x, y = E.vars("x", "y")
expr = E.op("+", x, E.op("*", 2, y))
Conditional Guards
Rules can have conditions that must be satisfied:
from rerum import RuleEngine, E, FULL_PRELUDE
engine = (RuleEngine()
.with_prelude(FULL_PRELUDE)
.load_dsl('''
@abs-pos: (abs ?x) => :x when (! > :x 0)
@abs-neg: (abs ?x) => (! - 0 :x) when (! < :x 0)
@abs-zero: (abs ?x) => 0 when (! = :x 0)
'''))
engine(E("(abs 5)")) # => 5
engine(E("(abs -5)")) # => 5
engine(E("(abs 0)")) # => 0
Guards use the (! ...) compute syntax and have access to type predicates:
const?- true for numbersvar?- true for symbols/variableslist?- true for compound expressions- Comparison:
>,<,>=,<=,=,!= - Logical:
and,or,not
Rule Priorities
Higher priority rules fire first:
engine = RuleEngine.from_dsl('''
@general: (+ ?x ?y) => (add :x :y)
@specific[100]: (+ 0 ?x) => :x # Fires first
@specific2[100]: (+ ?x 0) => :x # Fires first
''')
engine(E("(+ 0 y)")) # => "y" (specific rule wins)
engine(E("(+ a b)")) # => ["add", "a", "b"] (general rule)
Named Rulesets (Groups)
Organize rules into groups and selectively enable them:
engine = RuleEngine.from_dsl('''
[algebra]
@add-zero: (+ ?x 0) => :x
@mul-one: (* ?x 1) => :x
[calculus]
@dd-const: (dd ?c:const ?v:var) => 0
@dd-var: (dd ?x:var ?x) => 1
''')
# Use only algebra rules
engine(E("(+ x 0)"), groups=["algebra"])
# Disable a group
engine.disable_group("calculus")
# Get all group names
engine.groups() # => {"algebra", "calculus"}
Rewriting Strategies
Control how rules are applied:
# exhaustive (default): Apply rules repeatedly until fixpoint
result = engine(expr, strategy="exhaustive")
# once: Apply at most one rule anywhere
result = engine(expr, strategy="once")
# bottomup: Simplify children first, then parent
result = engine(expr, strategy="bottomup")
# topdown: Try parent first, then children
result = engine(expr, strategy="topdown")
Tracing
See which rules are applied:
result, trace = engine(expr, trace=True)
print(trace) # Verbose multi-line format
# Different formats
print(trace.format("compact")) # Single line
print(trace.format("rules")) # Just rule names
print(trace.format("chain")) # Step-by-step chain
# Statistics
print(trace.summary()) # Brief summary
print(trace.rule_counts()) # Rule usage counts
print(trace.rules_applied()) # List of rules in order
# Serialization
import json
json.dumps(trace.to_dict())
Preludes and Constant Folding
Preludes define computational primitives for the (! op ...) compute form:
from rerum import RuleEngine, ARITHMETIC_PRELUDE, MATH_PRELUDE, FULL_PRELUDE
# Fluent construction with prelude
engine = (RuleEngine()
.with_prelude(ARITHMETIC_PRELUDE)
.load_dsl('''
@fold: (+ ?a:const ?b:const) => (! + :a :b)
'''))
engine(E("(+ 1 2)")) # => 3
Available Preludes
| Prelude | Operations |
|---|---|
ARITHMETIC_PRELUDE |
+, -, *, /, ^ |
MATH_PRELUDE |
Arithmetic + sin, cos, tan, exp, log, sqrt, abs |
PREDICATE_PRELUDE |
>, <, =, const?, var?, list?, and, or, not |
FULL_PRELUDE |
Arithmetic + Predicates |
MINIMAL_PRELUDE |
+, * only |
NO_PRELUDE |
Empty (pure symbolic rewriting) |
Custom Preludes
from rerum import RuleEngine, nary_fold, unary_only, binary_only
my_prelude = {
"+": nary_fold(0, lambda a, b: a + b), # n-ary with identity
"max": binary_only(max), # binary only
"neg": unary_only(lambda x: -x), # unary only
"gcd": binary_only(math.gcd), # custom function
}
engine = RuleEngine().with_prelude(my_prelude).load_dsl(rules)
Engine Sequencing
Apply engines in phases:
expand = RuleEngine.from_dsl("@square: (square ?x) => (* :x :x)")
simplify = RuleEngine.from_dsl("@fold: (* ?a:const ?b:const) => (! * :a :b)",
fold_funcs=ARITHMETIC_PRELUDE)
# Sequence with >>
normalize = expand >> simplify
normalize(E("(square 3)")) # => 9
# Chain multiple phases
pipeline = expand >> simplify >> another_engine
Fluent API
from rerum import RuleEngine, FULL_PRELUDE
engine = (RuleEngine()
.with_prelude(FULL_PRELUDE)
.load_dsl('''
@add-zero: (+ ?x 0) => :x
''')
.load_file("more_rules.rules")
.add_rule(
pattern=E.op("+", ["?", "x"], ["?", "x"]),
skeleton=E.op("*", 2, [":", "x"]),
name="double"
)
.disable_group("experimental"))
# Pattern matching
if bindings := engine.match("(+ ?a ?b)", expr):
print(bindings["a"], bindings["b"])
# Apply single rule
result, meta = engine.apply_once(expr)
# Find matching rules
for meta, bindings in engine.rules_matching(expr):
print(f"Rule {meta.name} matches")
Variadic Patterns
Rest patterns (?x...) capture remaining arguments:
engine = RuleEngine.from_dsl('''
# Flatten nested additions
@flatten-add: (+ (+ ?a ?b) ?rest...) => (+ :a :b :rest...)
# Constant folding with rest
@fold-add: (+ ?a:const ?b:const ?rest...) => (+ (! + :a :b) :rest...)
''', fold_funcs=ARITHMETIC_PRELUDE)
engine(E("(+ (+ 1 2) 3 4)")) # => ["+", 1, 2, 3, 4] => 10
Equivalence, Proof, and Optimization
Bidirectional rules (<=>) let you reason over equivalence classes, not
just reduce expressions to normal form.
Bidirectional Rules
engine = RuleEngine.from_dsl('''
@comm-add: (+ ?x ?y) <=> (+ :y :x)
@assoc: (+ (+ ?x ?y) ?z) <=> (+ :x (+ :y :z))
@demorgan: (not (and ?x ?y)) <=> (or (not :x) (not :y))
''')
Each <=> rule expands into two unidirectional rules internally, so the
equivalence class is closed under both directions.
Proving Equality
prove_equal uses bidirectional BFS. It meets in the middle, so it handles
non-trivial equalities in milliseconds on small rule sets.
proof = engine.prove_equal(
["+", ["+", "a", "b"], "c"],
["+", "c", ["+", "b", "a"]],
max_depth=10,
max_expressions=5000, # optional work budget
)
if proof:
print(format_sexpr(proof.common))
print(f"Depths: a={proof.depth_a}, b={proof.depth_b}")
# Boolean shortcut
engine.are_equal(a, b)
Set max_expressions to bound un-provable queries (they otherwise exhaust
the full depth-bounded reachable set on both sides).
Minimizing Cost
minimize searches the equivalence class for the lowest-cost member.
from rerum import expr_size, expr_depth, make_op_cost_fn
# Built-in metric
result = engine.minimize(expr, metric="size") # or "depth", "ops", "atoms"
# Custom cost
result = engine.minimize(expr, cost=lambda e: expr_size(e) + 2*expr_depth(e))
# Per-operator costs
result = engine.minimize(expr, op_costs={"+": 1, "*": 2, "^": 10})
print(result.expr, result.cost, result.improvement_ratio)
By default, minimize uses both => and <=> rules, which matches how
users typically write simplification rules. Pass include_unidirectional=False
to restrict to strict reversible equivalences.
Enumerating and Sampling
# Lazy generator over the equivalence class
for eq in engine.equivalents(expr, max_depth=6):
...
# Eager list
forms = engine.enumerate_equivalents(expr, max_depth=10, max_count=1000)
# Random sampling
import random
rng = random.Random(42)
samples = engine.sample_equivalents(expr, n=10, unique=True, rng=rng)
equiv = engine.random_equivalent(expr, steps=20, rng=rng)
Under assoc + commute, the class of an n-term sum has exactly
n! × Catalan(n-1) members (2, 12, 120, 1680 for n = 2..5). Past n = 5
prefer prove_equal with a budget over full enumeration. See the
equivalence guide and
experiments/ for benchmarks.
API Reference
Creating Engines
# From DSL text
engine = RuleEngine.from_dsl("@add-zero: (+ ?x 0) => :x")
# From file
engine = RuleEngine.from_file("rules.rules") # DSL format
engine = RuleEngine.from_file("rules.json") # JSON format
# From Python lists
rules = [[["+", ["?", "x"], 0], [":", "x"]]]
engine = RuleEngine.from_rules(rules)
# With prelude
engine = RuleEngine.from_dsl(dsl_text, fold_funcs=ARITHMETIC_PRELUDE)
Using Engines
# Simplify (callable shorthand)
result = engine(expr)
# With options
result = engine(expr, strategy="bottomup", groups=["algebra"])
# With tracing
result, trace = engine(expr, trace=True)
Inspecting Engines
len(engine) # Number of rules
"add-zero" in engine # Check if rule exists
rule, meta = engine["add-zero"] # Get by name
engine.list_rules() # DSL format strings
engine.groups() # All group names
for rule, meta in engine: # Iterate
print(meta.name, meta.description)
Combining Engines
algebra = RuleEngine.from_file("algebra.rules")
calculus = RuleEngine.from_file("calculus.rules")
combined = algebra | calculus # Union
algebra |= calculus # In-place union
phased = algebra >> calculus # Sequence (algebra first, then calculus)
JSON Format
Rules can also be loaded from JSON:
{
"name": "algebra",
"description": "Basic algebraic rules",
"rules": [
{
"name": "add-zero",
"description": "x + 0 = x",
"pattern": ["+", ["?", "x"], 0],
"skeleton": [":", "x"]
}
]
}
Architecture
+-------------------------------------+
| Rules (DSL/JSON) - Serializable |
| - Pattern matching |
| - Symbolic transformation |
| - (! op ...) references operations |
| - Conditions reference predicates |
+------------------+------------------+
| references
v
+-------------------------------------+
| Prelude (Python) - Trusted Code |
| - Defines what operations do |
| - Provided by developer |
| - Cannot be injected from rules |
+-------------------------------------+
Rules loaded from untrusted sources cannot execute arbitrary code - they can only invoke operations explicitly enabled in the prelude.
Low-Level API
For advanced use cases:
from rerum import rewriter, match, instantiate, parse_sexpr, format_sexpr
# Create a rewriter function directly
simplify = rewriter(rules, fold_funcs=ARITHMETIC_PRELUDE)
result = simplify(expr)
# Pattern matching
bindings = match(pattern, expr, [])
if bindings != "failed":
result = instantiate(skeleton, bindings, fold_funcs)
# S-expression parsing
expr = parse_sexpr("(+ x (* 2 y))") # => ["+", "x", ["*", 2, "y"]]
text = format_sexpr(expr) # => "(+ x (* 2 y))"
Command-Line Interface
RERUM includes a CLI for interactive use and scripting.
REPL Mode
$ rerum
rerum> @add-zero: (+ ?x 0) => :x
Added 1 rule(s)
rerum> (+ y 0)
y
rerum> :quit
Script Mode
Create a .rerum file:
#!/usr/bin/env rerum
:prelude full
# Symbolic rule: transforms structure, no computation
@square: (square ?x) => (* :x :x)
# Computation rule: (!) evaluates when args are constants
@fold-mul: (* ?a ?b) => (! * :a :b) when (! and (! const? :a) (! const? :b))
# (square x) => (* x x) (symbolic, x stays as x)
(square x)
# (square 5) => (* 5 5) => 25 (fold-mul computes the result)
(square 5)
Output:
(* x x)
25
Run scripts:
$ rerum script.rerum
$ chmod +x script.rerum && ./script.rerum # With shebang
One-Shot Mode
$ rerum -r rules.rules -p full -e "(+ x 0)"
x
Pipe Mode
$ echo "(+ x 0)" | rerum -r rules.rules -p full -q
x
CLI Options
rerum [script] Run a script or start REPL
-r, --rules FILE Load rules (can repeat)
-e, --expr EXPR Evaluate single expression
-p, --prelude NAME Set prelude (arithmetic, math, full, none, or path.py)
-t, --trace Enable tracing
-s, --strategy NAME Strategy: exhaustive, once, bottomup, topdown
-q, --quiet Suppress non-essential output
REPL Commands
:help Show help
:load FILE Load rules from file
:rules List loaded rules
:clear Clear all rules
:prelude NAME Set prelude
:trace on|off Toggle tracing
:strategy NAME Set rewriting strategy
:groups Show groups
:enable GROUP Enable a group
:disable GROUP Disable a group
:quit Exit
Custom Preludes
Create a Python file with a PRELUDE dict:
# my_prelude.py
from rerum import binary_only, unary_only
import math
PRELUDE = {
"gcd": binary_only(math.gcd),
"factorial": unary_only(math.factorial),
}
Use it:
$ rerum -p my_prelude.py -r rules.rules
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file rerum-0.5.0.tar.gz.
File metadata
- Download URL: rerum-0.5.0.tar.gz
- Upload date:
- Size: 91.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e1215647a7f08c1b34a42335c3db6e2012ec1ef3ebd73e6d786d3a0af9d8d81d
|
|
| MD5 |
256335e46bdb8d4da5efd935c83ed6c5
|
|
| BLAKE2b-256 |
8393ffafe776c8fad088141c96e1fb10edd54349d14d3777e39f2fb403641980
|
File details
Details for the file rerum-0.5.0-py3-none-any.whl.
File metadata
- Download URL: rerum-0.5.0-py3-none-any.whl
- Upload date:
- Size: 80.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1a7f1f88e148ab374925ef11083f4054efdaabfaedc542e5037d7619190cdc8c
|
|
| MD5 |
471050e37fb95ba3834b027393fac99f
|
|
| BLAKE2b-256 |
72d16640db2d22bcbfcc7652de8c1abcf0856ad67facaed866d88dc4aa276fe7
|