Algorithm Search⌘K

Earley parser in Python

Chart-based parser handling all context-free grammars in cubic time, linear for unambiguous ones.

File
earley_parser.py, 194 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
earley_parser.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/earley-parser/. The authors are listed in prov/X30PDD.jsonl.

"""Earley's recognizer and parser for any context-free grammar.

A grammar is a list of productions (A, rhs), where rhs is a tuple of symbols.
The nonterminals are the left sides; every other symbol is a terminal. Empty
right sides, unit productions, left recursion, cycles and ambiguity are all
allowed. Empty productions are handled by the nullable-prediction rule of
Aycock and Horspool. An item (p, dot, origin) in set j stands for
[A -> alpha . beta, origin] where productions[p] = (A, alpha beta) and
len(alpha) = dot. A parse tree is (A, children), where each child is a tree or
a terminal symbol.
"""

from typing import Optional, Sequence, Union

Tree = tuple[str, tuple[Union["Tree", str], ...]]
Production = tuple[str, tuple[str, ...]]
Item = tuple[int, int, int]


def nullable_set(productions: Sequence[Production]) -> tuple[set[str], dict[str, int]]:
    """Return (N, why): N is the set of nonterminals that derive the empty
    string and why[A] is a production of A whose right side is made of
    nonterminals that became nullable before A (used to build empty trees)."""
    nullable: set[str] = set()
    why: dict[str, int] = {}
    changed = True
    while changed:
        changed = False
        for p, (a, rhs) in enumerate(productions):
            if a not in nullable and all(x in nullable for x in rhs):
                nullable.add(a)
                why[a] = p
                changed = True
    return nullable, why


class _Chart:
    """The item sets E[0..n] with one back pointer per item (its first creation)."""

    def __init__(self, n: int) -> None:
        self.items: list[list[Item]] = [[] for _ in range(n + 1)]
        self.seen: list[dict[Item, object]] = [{} for _ in range(n + 1)]
        self.waiting: list[dict[str, list[Item]]] = [{} for _ in range(n + 1)]

    def add(self, j: int, item: Item, after: Optional[str], back: object) -> None:
        if item in self.seen[j]:
            return
        self.seen[j][item] = back
        self.items[j].append(item)
        if after is not None:
            self.waiting[j].setdefault(after, []).append(item)


def _run(productions: Sequence[Production], start: str, w: Sequence[str]):
    nts = {a for a, _ in productions}
    by_lhs: dict[str, list[int]] = {}
    for p, (a, _) in enumerate(productions):
        by_lhs.setdefault(a, []).append(p)
    nullable, why = nullable_set(productions)                         # @line L1
    n = len(w)
    chart = _Chart(n)                                                 # @line L2

    def after(p: int, dot: int) -> Optional[str]:
        rhs = productions[p][1]
        return rhs[dot] if dot < len(rhs) else None

    for p in by_lhs.get(start, []):                                   # @line L3
        chart.add(0, (p, 0, 0), after(p, 0), None)
    for j in range(n + 1):                                            # @line L4
        predicted: set[str] = set()
        completed: set[tuple[str, int]] = set()
        k = 0
        while k < len(chart.items[j]):                                # @line L5
            item = chart.items[j][k]
            k += 1
            p, dot, origin = item
            lhs, rhs = productions[p]
            if dot == len(rhs):                                       # @line L6
                if (lhs, origin) in completed:
                    continue
                completed.add((lhs, origin))
                for q, qdot, qorig in list(chart.waiting[origin].get(lhs, [])):  # @line L7
                    nxt = (q, qdot + 1, qorig)
                    chart.add(j, nxt, after(q, qdot + 1), ("c", origin, (q, qdot, qorig), item))
            elif rhs[dot] in nts:                                     # @line L8
                x = rhs[dot]
                if x not in predicted:
                    predicted.add(x)
                    for q in by_lhs[x]:                               # @line L9
                        chart.add(j, (q, 0, j), after(q, 0), None)
                if x in nullable:                                     # @line L10
                    chart.add(j, (p, dot + 1, origin), after(p, dot + 1), ("e", item, x))
            elif j < n and rhs[dot] == w[j]:                          # @line L11
                chart.add(j + 1, (p, dot + 1, origin), after(p, dot + 1), ("t", item, w[j]))  # @line L12
    return chart, nullable, why


def earley_chart(productions: Sequence[Production], start: str, w: Sequence[str]) -> list[list[Item]]:
    """Return the item sets E[0], ..., E[n] as lists of (p, dot, origin), in insertion order."""
    if not any(a == start for a, _ in productions):
        return [[] for _ in range(len(w) + 1)]
    chart, _, _ = _run(productions, start, w)
    return chart.items


def _final_item(productions: Sequence[Production], start: str, chart: _Chart, n: int) -> Optional[Item]:
    for p, dot, origin in chart.items[n]:                             # @line L13
        a, rhs = productions[p]
        if a == start and dot == len(rhs) and origin == 0:
            return (p, dot, origin)
    return None


def earley_recognize(productions: Sequence[Production], start: str, w: Sequence[str]) -> bool:
    """Return True if and only if start derives the string w."""
    if not any(a == start for a, _ in productions):
        return False
    chart, _, _ = _run(productions, start, w)
    return _final_item(productions, start, chart, len(w)) is not None


def _empty_trees(productions: Sequence[Production], nullable: set[str], why: dict[str, int]) -> dict[str, Tree]:
    """One finite tree deriving the empty string for each nullable nonterminal.
    why[A] uses only nonterminals made nullable earlier, so a pass in that order works."""
    trees: dict[str, Tree] = {}
    for a in why:
        rhs = productions[why[a]][1]
        trees[a] = (a, tuple(trees[x] for x in rhs))
    return trees


def earley_parse(productions: Sequence[Production], start: str, w: Sequence[str]) -> Optional[Tree]:
    """Return one parse tree of w from start, or None if w is not in the language.

    Each item keeps the back pointer of its first creation, which points to items
    created strictly earlier, so following the pointers ends. The tree is built
    with an explicit stack, without recursion."""
    if not any(a == start for a, _ in productions):
        return None
    chart, nullable, why = _run(productions, start, w)
    n = len(w)
    root = _final_item(productions, start, chart, n)
    if root is None:
        return None
    eps = _empty_trees(productions, nullable, why)

    def children_refs(j: int, item: Item) -> list[tuple]:
        """Walk the back pointers of a completed item to its dot-0 item."""
        refs: list[tuple] = []
        cur_j, cur = j, item
        while True:
            back = chart.seen[cur_j][cur]
            if back is None:
                break
            kind = back[0]
            if kind == "t":
                refs.append(("t", back[2]))
                cur, cur_j = back[1], cur_j - 1
            elif kind == "e":
                refs.append(("e", back[2]))
                cur = back[1]
            else:
                _, mid, prev, child = back
                refs.append(("c", cur_j, child))
                cur, cur_j = prev, mid
        refs.reverse()
        return refs

    built: dict[tuple[int, Item], Tree] = {}
    stack: list[tuple[int, Item, bool]] = [(n, root, False)]
    while stack:
        j, item, ready = stack.pop()
        if (j, item) in built:
            continue
        refs = children_refs(j, item)
        if not ready:
            stack.append((j, item, True))
            for r in refs:
                if r[0] == "c" and (r[1], r[2]) not in built:
                    stack.append((r[1], r[2], False))
            continue
        kids: list[Union[Tree, str]] = []
        for r in refs:
            if r[0] == "t":
                kids.append(r[1])
            elif r[0] == "e":
                kids.append(eps[r[1]])
            else:
                kids.append(built[(r[1], r[2])])
        built[(j, item)] = (productions[item[0]][0], tuple(kids))
    return built[(n, root)]
test_earley_parser.py 181 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/earley-parser/. The authors are listed in prov/X30PDD.jsonl.

import itertools
import random

from earley_parser import earley_chart, earley_parse, earley_recognize, nullable_set

EXAMPLE = [
    ("E", ("E", "+", "T")), ("E", ("T",)),
    ("T", ("a",)), ("T", ()),
]


def derives(productions, start, w):
    """Independent oracle: least fixpoint of 'A derives w[i:j]' over all spans."""
    nts = {a for a, _ in productions}
    n = len(w)
    known = set()

    def seq(rhs, i, j):
        ends = {i}
        for x in rhs:
            nxt = set()
            for p in ends:
                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)
            ends = nxt
        return j in ends

    changed = True
    while changed:
        changed = False
        for a, rhs in productions:
            for i in range(n + 1):
                for j in range(i, n + 1):
                    if (a, i, j) not in known and seq(rhs, i, j):
                        known.add((a, i, j))
                        changed = True
    return (start, 0, n) in known


def naive_items(productions, start, w):
    """Independent oracle for the chart: close the three Earley rules
    (predict, scan, complete) by brute force until nothing changes."""
    nts = {a for a, _ in productions}
    n = len(w)
    items = {(p, 0, 0, 0) for p, (a, _) in enumerate(productions) if a == start}
    changed = True
    while changed:
        changed = False
        new = set()
        for p, dot, i, j in items:
            rhs = productions[p][1]
            if dot == len(rhs):
                a = productions[p][0]
                for q, qd, qi, qj in items:
                    r = productions[q][1]
                    if qj == i and qd < len(r) and r[qd] == a:
                        new.add((q, qd + 1, qi, j))
            elif rhs[dot] in nts:
                for q, (b, _) in enumerate(productions):
                    if b == rhs[dot]:
                        new.add((q, 0, j, j))
            elif j < n and w[j] == rhs[dot]:
                new.add((p, dot + 1, i, j + 1))
        if not new <= items:
            items |= new
            changed = True
    sets = [set() for _ in range(n + 1)]
    for p, dot, i, j in items:
        sets[j].add((p, dot, i))
    return sets


def tree_ok(tree, productions, start, w):
    """The tree has root start, uses only productions and its leaves spell w."""
    prods = {(a, tuple(r)) for a, r in productions}
    if tree[0] != start:
        return False
    leaves = []
    stack = [tree]
    while stack:
        t = stack.pop()
        if isinstance(t, str):
            leaves.append(t)
            continue
        label, kids = t
        rhs = tuple(k if isinstance(k, str) else k[0] for k in kids)
        if (label, rhs) not in prods:
            return False
        stack.extend(reversed(kids))
    return leaves == list(w)


def random_grammar(rng):
    nts = ["S", "A", "B", "C"][: rng.randint(1, 4)]
    syms = nts + ["a", "b"]
    prods = set()
    for a in nts:
        for _ in range(rng.randint(1, 3)):
            if rng.random() < 0.2:
                prods.add((a, ()))
            else:
                prods.add((a, tuple(rng.choice(syms) for _ in range(rng.randint(1, 3)))))
    return sorted(prods)


def test_example_chart():
    w = ["a", "+", "a"]
    chart = earley_chart(EXAMPLE, "E", w)
    assert chart == [
        [(0, 0, 0), (1, 0, 0), (0, 1, 0), (2, 0, 0), (3, 0, 0), (1, 1, 0)],
        [(2, 1, 0), (1, 1, 0), (0, 1, 0)],
        [(0, 2, 0), (2, 0, 2), (3, 0, 2), (0, 3, 0), (0, 1, 0)],
        [(2, 1, 2), (0, 3, 0), (0, 1, 0)],
    ]
    assert earley_recognize(EXAMPLE, "E", w)
    tree = earley_parse(EXAMPLE, "E", w)
    assert tree == ("E", (("E", (("T", ("a",)),)), "+", ("T", ("a",))))


def test_edge_cases():
    g = [("S", ("a", "S")), ("S", ())]
    assert earley_recognize(g, "S", [])
    assert earley_parse(g, "S", []) == ("S", ())
    assert earley_recognize(g, "S", list("aaaa"))
    assert not earley_recognize(g, "S", ["b"])
    assert not earley_recognize([("S", ("a",))], "S", [])
    assert not earley_recognize([("A", ("a",))], "S", ["a"])
    assert earley_parse([("A", ("a",))], "S", ["a"]) is None
    assert nullable_set([("S", ("A", "B")), ("A", ()), ("B", ("A",))])[0] == {"S", "A", "B"}


def test_cycles_and_hidden_empty():
    g = [("S", ("S",)), ("S", ("A", "S", "b")), ("S", ("a",)), ("A", ()), ("A", ("A", "A"))]
    for m in range(5):
        w = ["a"] + ["b"] * m
        assert earley_recognize(g, "S", w)
        assert tree_ok(earley_parse(g, "S", w), g, "S", w)
    assert not earley_recognize(g, "S", ["b"])


def test_ambiguous_and_left_recursive():
    g = [("S", ("S", "S")), ("S", ("a",))]
    for m in range(1, 9):
        w = ["a"] * m
        assert tree_ok(earley_parse(g, "S", w), g, "S", w)
    left = [("S", ("S", "a")), ("S", ())]
    right = [("S", ("a", "S")), ("S", ())]
    for m in range(0, 30):
        w = ["a"] * m
        assert earley_recognize(left, "S", w) and earley_recognize(right, "S", w)


def test_long_input_no_recursion_limit():
    g = [("S", ("a", "S")), ("S", ("a",))]
    w = ["a"] * 3000
    tree = earley_parse(g, "S", w)
    assert tree_ok(tree, g, "S", w)


def test_random_against_oracles():
    rng = random.Random(20261106)
    words = [list(t) for m in range(5) for t in itertools.product("ab", repeat=m)]
    checked = 0
    for _ in range(700):
        g = random_grammar(rng)
        for w in [[]] + rng.sample(words, 5):
            expect = derives(g, "S", w)
            assert earley_recognize(g, "S", w) == expect, (g, w)
            tree = earley_parse(g, "S", w)
            assert (tree is not None) == expect
            if expect:
                assert tree_ok(tree, g, "S", w), (g, w, tree)
            if len(w) <= 3:
                assert [set(s) for s in earley_chart(g, "S", w)] == naive_items(g, "S", w)
            checked += 1
    assert checked >= 500

What the next level needs

  • the tests did not run in this build

Read the full page about Earley parser