Algorithm Search⌘K

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
parsing_with_derivatives.pyRawGitHub
# 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

Read the full page about Parsing with derivatives