Parsing with derivatives in Python
Recognizes strings by symbolically differentiating the grammar with respect to each input token.
- File
parsing_with_derivatives.py, 236 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/parsing-with-derivatives/. The authors are listed in prov/FA07T5.jsonl.
"""Recognition of context-free languages by Brzozowski derivatives (Might, Darais
and Spiewak 2011), with memoized, lazily tied derivatives and a linear-time
nullability fixed point, as analyzed by Adams, Hollenbeck and Might (2016).
A language is a node of a possibly cyclic graph: EMPTY (no strings), EPS (only
the empty string), a token, a union a | b, or a concatenation a . b. A grammar
is a list of productions (A, rhs); the nonterminals are the left sides, and
every other symbol in a right side is a token.
"""
class Node:
__slots__ = ("kind", "a", "b", "tok", "nullable")
def __init__(self, kind, a=None, b=None, tok=None):
self.kind = kind # "empty", "eps", "tok", "alt" or "cat"
self.a, self.b, self.tok = a, b, tok
self.nullable = None # None until decided
def __repr__(self):
return f"<{self.kind}>"
EMPTY = Node("empty")
EMPTY.nullable = False
EPS = Node("eps")
EPS.nullable = True
def from_grammar(prods, start):
"""The language node of start: each nonterminal becomes a union of its
productions, and each production a right-nested concatenation."""
nts = {a for a, _ in prods} | {start}
top = {a: Node("alt") for a in nts}
toks = {}
def sym(x):
if x in nts:
return top[x]
if x not in toks:
toks[x] = Node("tok", tok=x)
return toks[x]
for a in nts:
alts = [rhs for b, rhs in prods if b == a]
seqs = []
for rhs in alts:
node = EPS
if rhs:
node = sym(rhs[-1])
for x in reversed(rhs[:-1]):
node = Node("cat", sym(x), node)
seqs.append(node)
# top[a] = seq1 | (seq2 | (... | EMPTY))
rest = EMPTY
for node in reversed(seqs[1:]):
rest = Node("alt", node, rest)
top[a].a = seqs[0] if seqs else EMPTY
top[a].b = rest
return top[start]
def nullable(root):
"""Least fixed point of: EPS is nullable, a | b if a or b is, a . b if both are.
Decides every undecided node reachable from root, in time linear in their number."""
if root.nullable is not None:
return root.nullable
found, todo = [root], [root] # @line L11
seen = {id(root)}
users = {}
while todo:
x = todo.pop()
for y in (x.a, x.b) if x.kind in ("alt", "cat") else ():
users.setdefault(id(y), []).append(x)
if y.nullable is None and id(y) not in seen:
seen.add(id(y))
found.append(y)
todo.append(y)
# need[x] = number of children still to be proven nullable before x is
need = {}
for x in found:
if x.kind == "eps":
need[id(x)] = 0
elif x.kind == "alt":
need[id(x)] = 0 if (x.a.nullable or x.b.nullable) else 1
elif x.kind == "cat":
need[id(x)] = (x.a.nullable is not True) + (x.b.nullable is not True)
if x.a.nullable is False or x.b.nullable is False:
need[id(x)] = 3 # can never become nullable
else:
need[id(x)] = 3
# decide only after all counts are taken, so that no child is counted twice
queue = [x for x in found if need[id(x)] == 0]
for x in queue:
x.nullable = True # @line L12
while queue:
y = queue.pop()
for x in users.get(id(y), ()):
if x.nullable is None and id(x) in need:
need[id(x)] -= 1
if need[id(x)] == 0:
x.nullable = True
queue.append(x)
for x in found:
if x.nullable is None:
x.nullable = False # @line L13
return root.nullable
def compact(x):
"""A node with the same language as x, skipping trivial nodes: a union with
EMPTY on one side, a concatenation with EMPTY on one side (which is EMPTY),
and a concatenation EPS . y, which is y. A cycle of such skips denotes the
empty language (it is the least fixed point of x = x)."""
seen = set()
while id(x) not in seen:
seen.add(id(x))
if x.kind == "alt":
if x.a is EMPTY:
x = x.b
elif x.b is EMPTY:
x = x.a
else:
return x
elif x.kind == "cat":
if x.a is EMPTY or x.b is EMPTY:
return EMPTY
if x.a is EPS:
x = x.b
else:
return x
else:
return x
return EMPTY
class Deriver:
"""Memoized derivatives. memo[(id(node), token)] is the derivative node; the
nodes are kept alive in keep so that ids are not reused."""
def __init__(self):
self.memo = {}
self.keep = []
self.created = 0
def derive(self, root, c):
stack = []
result = self._get(root, c, stack)
while stack:
x, u = stack.pop()
if x.kind == "alt": # @line L7
u.a = self._get(x.a, c, stack)
u.b = self._get(x.b, c, stack)
elif u.kind == "cat": # @line L8
u.a = self._get(x.a, c, stack)
u.b = x.b
else: # @line L9
u.a.a = self._get(x.a, c, stack)
u.a.b = x.b
u.b = self._get(x.b, c, stack)
return result
def _get(self, x, c, stack):
"""The node for D_c(x): a constant for a leaf, otherwise a memoized node
whose children are filled in later from the stack."""
x = compact(x) # @line L3
if x.kind in ("empty", "eps"): # @line L4
return EMPTY
if x.kind == "tok": # @line L5
return EPS if x.tok == c else EMPTY
key = (id(x), c)
u = self.memo.get(key) # @line L6
if u is not None:
return u
if x.kind == "alt":
u = Node("alt")
self.created += 1
elif not nullable(x.a):
u = Node("cat")
self.created += 1
else:
u = Node("alt", Node("cat"))
self.created += 2
self.memo[key] = u
self.keep.append(x)
stack.append((x, u))
return u # @line L10
def recognize(prods, start, tokens, stats=None):
"""True when the token sequence is in L(G). If stats is a dict, the number of
derivative nodes created is stored under "nodes"."""
lang = from_grammar(prods, start)
d = Deriver()
for c in tokens: # @line L1
lang = d.derive(lang, c)
if stats is not None:
stats["nodes"] = d.created
return nullable(lang) # @line L2
def recognize_naive(lang, tokens, limit=10**6):
"""The derivative without memoization, for acyclic languages only (no recursion);
counts the nodes built, to show the duplication that memoization removes."""
built = [0]
def nul(x):
if x.kind == "eps":
return True
if x.kind == "alt":
return nul(x.a) or nul(x.b)
if x.kind == "cat":
return nul(x.a) and nul(x.b)
return False
def der(x, c):
if built[0] > limit:
raise OverflowError
if x.kind in ("empty", "eps"):
return EMPTY
if x.kind == "tok":
return EPS if x.tok == c else EMPTY
built[0] += 1
if x.kind == "alt":
return Node("alt", der(x.a, c), der(x.b, c))
left = Node("cat", der(x.a, c), x.b)
if nul(x.a):
built[0] += 1
return Node("alt", left, der(x.b, c))
return left
for c in tokens:
lang = der(lang, c)
return nul(lang), built[0]
test_parsing_with_derivatives.py 174 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/parsing-with-derivatives/. The authors are listed in prov/FA07T5.jsonl.
import itertools
import random
from parsing_with_derivatives import (
EMPTY,
EPS,
Deriver,
from_grammar,
nullable,
recognize,
recognize_naive,
)
def grammar(text):
prods = []
for rule in text.split(";"):
a, rhs = rule.split("->")
for alt in rhs.split("|"):
prods.append((a.strip(), tuple(x for x in alt.split() if x != "eps")))
return prods
def derives_whole(prods, start, w):
"""Oracle: does start derive w? The set of (X, i, j) with X =>* w[i:j], by a
least fixpoint over all splits."""
nts = {a for a, _ in prods} | {start}
n, known = len(w), set()
def ends(rhs, i, j):
cur = {i}
for x in rhs:
nxt = set()
for p in cur:
if x in nts:
nxt |= {q for q in range(p, j + 1) if (x, p, q) in known}
elif p < j and w[p] == x:
nxt.add(p + 1)
cur = nxt
return cur
changed = True
while changed:
changed = False
for a, rhs in prods:
for i in range(n + 1):
for j in range(i, n + 1):
if (a, i, j) not in known and j in ends(rhs, i, j):
known.add((a, i, j))
changed = True
return (start, 0, n) in known
def random_grammar(rng):
names = ["S", "A", "B"][: rng.randint(1, 3)]
prods = []
for a in names:
for _ in range(rng.randint(1, 3)):
k = rng.choice([0, 1, 1, 2, 2, 3])
prods.append((a, tuple(rng.choice(names + ["a", "b", "a", "b"]) for _ in range(k))))
return list(dict.fromkeys(prods))
def test_random_grammars_against_brute_force():
rng = random.Random(2011)
checked = accepted = 0
kinds = {"eps": 0, "unit": 0, "left": 0, "cycle": 0, "empty_input_yes": 0, "empty_input_no": 0}
for _ in range(500):
g = random_grammar(rng)
start = g[0][0]
nts = {a for a, _ in g}
kinds["eps"] += any(not r for _, r in g)
kinds["unit"] += any(len(r) == 1 and r[0] in nts for _, r in g)
kinds["left"] += any(r and r[0] == a for a, r in g)
kinds["cycle"] += any(r == (a,) for a, r in g)
for n in range(6):
for w in itertools.product("ab", repeat=n):
want = derives_whole(g, start, list(w))
stats = {}
got = recognize(g, start, w, stats)
assert got == want, (g, w)
# the bound of Adams, Hollenbeck and Might: O(|G| n^3) nodes
size = sum(len(r) + 1 for _, r in g) + len(nts)
assert stats["nodes"] <= 4 * size * (n + 1) ** 3
checked += 1
accepted += got
if n == 0:
kinds["empty_input_yes" if got else "empty_input_no"] += 1
assert checked >= 30000 and accepted >= 1500 and checked - accepted >= 2000
assert min(kinds.values()) >= 50, kinds
def test_page_example():
g = grammar("S -> S + n | n")
s = from_grammar(g, "S")
plus_n = s.a.b # the node + . n
assert s.kind == "alt" and s.a.a is s and plus_n.kind == "cat"
d = Deriver()
d1 = d.derive(s, "n")
# S1 = S1 . (+ n) | eps: the memo table ties the cycle
assert d1.kind == "alt" and d1.a.kind == "cat" and d1.a.a is d1 and d1.a.b is plus_n
assert d1.b is EPS and nullable(d1) and d.created == 2
d2 = d.derive(d1, "+")
# S2 = (S2 . (+ n) | eps . n) | empty
assert d2.kind == "alt" and d2.b is EMPTY and d2.a.kind == "alt"
assert d2.a.a.kind == "cat" and d2.a.a.a is d2 and d2.a.a.b is plus_n
assert d2.a.b.kind == "cat" and d2.a.b.a is EPS and d2.a.b.b.tok == "n"
assert not nullable(d2) and d.created == 6
d3 = d.derive(d2, "n")
# S3 = S3 . (+ n) | eps, the same shape as S1
assert d3.kind == "alt" and d3.a.a is d3 and d3.a.b is plus_n and d3.b is EPS
assert nullable(d3) and d.created == 8
stats = {}
assert recognize(g, "S", ["n", "+", "n"], stats) and stats["nodes"] == 8
assert not recognize(g, "S", ["n", "+"])
assert not recognize(g, "S", [])
assert not recognize(g, "S", ["+", "n"])
def test_cycles_and_epsilon():
assert recognize(grammar("S -> S | a"), "S", ["a"])
assert not recognize(grammar("S -> S | a"), "S", [])
assert recognize(grammar("S -> S S | eps"), "S", [])
assert not recognize(grammar("S -> S S | eps"), "S", ["a"])
assert not recognize(grammar("S -> S"), "S", [])
assert not recognize(grammar("S -> a S"), "S", ["a", "a"]) # empty language
assert recognize(grammar("S -> A B; A -> eps | a A; B -> eps | b B"), "S", list("aabbb"))
assert not recognize(grammar("S -> A B; A -> eps | a A; B -> eps | b B"), "S", list("aba"))
def test_dyck_and_palindromes():
dyck = grammar("S -> ( S ) S | eps")
rng = random.Random(5)
for _ in range(300):
w = [rng.choice("()") for _ in range(rng.randrange(0, 14))]
depth, ok = 0, True
for c in w:
depth += 1 if c == "(" else -1
ok = ok and depth >= 0
assert recognize(dyck, "S", w) == (ok and depth == 0)
pal = grammar("P -> a P a | b P b | a | b | eps")
for n in range(9):
for w in itertools.product("ab", repeat=n):
assert recognize(pal, "P", w) == (list(w) == list(reversed(w)))
def test_memoization_removes_duplication():
# L = (a | eps)^k, no recursion: the naive derivative copies the tail twice per nullable head
k = 12
g = grammar("S -> " + " ".join(["A"] * k) + "; A -> a | eps")
for n in range(0, 8):
w = ["a"] * n
stats = {}
got = recognize(g, "S", w, stats)
ok, built = recognize_naive(from_grammar(g, "S"), w)
assert got == ok == (n <= k)
assert stats["nodes"] <= built
stats = {}
recognize(g, "S", ["a"] * 6, stats)
_, built = recognize_naive(from_grammar(g, "S"), ["a"] * 6)
assert built > 20 * stats["nodes"]
def test_long_inputs_iterative():
n = 3000
assert recognize(grammar("S -> a S | eps"), "S", ["a"] * n)
w = ["n"] + ["+", "n"] * 1500
assert recognize(grammar("S -> S + n | n"), "S", w)
assert not recognize(grammar("S -> S + n | n"), "S", w + ["+"])
nested = ["("] * 300 + [")"] * 300
assert recognize(grammar("S -> ( S ) S | eps"), "S", nested)
What the next level needs
- the tests did not run in this build