Algorithm Search⌘K

Left-corner parsing in Python

Hybrid parser that predicts from the leftmost symbol of each production.

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

"""Generalized left-corner recognizer 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 and the start symbol; every other symbol
is a terminal. Empty right sides, unit productions, cycles, left recursion and
ambiguity are all allowed.

A left-corner parser finds the first symbol of each production bottom-up, from
the input, and the other symbols top-down, as goals. This recognizer runs all
choices of such a parser at once with a chart. An item [C, i, B -> alpha . beta]
in E[k] says: while looking for the goal C from position i, the parser has
found alpha from i to k, and B is a left corner of C. DONE holds the triples
(C, i, k) such that the goal C was found from i to k.
"""

from typing import Sequence

Production = tuple[str, tuple[str, ...]]
Item = tuple[str, int, int, int]        # (goal C, start i, production index, dot)


def left_corner_closure(productions: Sequence[Production], start: str) -> dict[str, set[str]]:
    """L[C]: the nonterminals B with C = B or C -> B ... -> ... B ... (first symbols)."""
    nts = {a for a, _ in productions} | {start}
    first: dict[str, set[str]] = {a: set() for a in nts}
    for a, rhs in productions:
        if rhs and rhs[0] in nts:
            first[a].add(rhs[0])
    closure: dict[str, set[str]] = {}
    for c in nts:
        seen = {c}
        todo = [c]
        while todo:
            b = todo.pop()
            for d in first[b]:
                if d not in seen:
                    seen.add(d)
                    todo.append(d)
        closure[c] = seen
    return closure


def left_corner_chart(productions: Sequence[Production], start: str, w: Sequence[str]
                      ) -> tuple[list[list[Item]], dict[tuple[str, int], set[int]]]:
    """Return the chart E[0..n] and DONE as a map (C, i) -> set of ends k."""
    prods = [(a, tuple(r)) for a, r in productions]
    nts = {a for a, _ in prods} | {start}
    n = len(w)
    lc = left_corner_closure(prods, start)                               # @line L1
    by_first: dict[str, list[int]] = {}        # productions D -> X gamma, by X
    empty: list[int] = []                      # productions D -> epsilon
    for p, (a, rhs) in enumerate(prods):
        if rhs:
            by_first.setdefault(rhs[0], []).append(p)
        else:
            empty.append(p)
    chart: list[list[Item]] = [[] for _ in range(n + 1)]
    seen: list[set[Item]] = [set() for _ in range(n + 1)]
    done: dict[tuple[str, int], set[int]] = {}
    found: list[set[tuple[str, int, str]]] = [set() for _ in range(n + 1)]
    waiting: dict[tuple[str, int], list[Item]] = {}

    def add(item: Item, k: int) -> None:
        if item not in seen[k]:
            seen[k].add(item)
            chart[k].append(item)

    def start_goal(c: str, k: int) -> None:
        done[(c, k)] = set()
        waiting.setdefault((c, k), [])
        if k < n:                                                        # @line L14
            for p in by_first.get(w[k], []):
                if w[k] not in nts and prods[p][0] in lc[c]:
                    add((c, k, p, 1), k + 1)
        for p in empty:                                                  # @line L15
            if prods[p][0] in lc[c]:
                add((c, k, p, 0), k)

    start_goal(start, 0)                                                 # @line L2
    for k in range(n + 1):                                               # @line L3
        idx = 0
        while idx < len(chart[k]):                                       # @line L4
            c, i, p, d = chart[k][idx]
            idx += 1
            b, rhs = prods[p]
            if d == len(rhs):                                            # @line L5
                if (c, i, b) in found[k]:      # B was already found from i to k under C
                    continue
                found[k].add((c, i, b))
                if b == c and k not in done[(c, i)]:                     # @line L6
                    done[(c, i)].add(k)
                    for (c2, i2, p2, d2) in waiting[(c, i)]:
                        add((c2, i2, p2, d2 + 1), k)
                for q in by_first.get(b, []):                            # @line L7
                    if prods[q][0] in lc[c]:
                        add((c, i, q, 1), k)
            elif rhs[d] in nts:                                          # @line L8
                y = rhs[d]
                if (y, k) not in done:                                   # @line L10
                    start_goal(y, k)
                waiting[(y, k)].append((c, i, p, d))                     # @line L9
                for k2 in list(done[(y, k)]):                            # @line L11
                    add((c, i, p, d + 1), k2)
            elif k < n and rhs[d] == w[k]:                               # @line L12
                add((c, i, p, d + 1), k + 1)
    return chart, done


def left_corner_recognize(productions: Sequence[Production], start: str, w: Sequence[str]) -> bool:
    """Return True if w is in the language of the grammar."""
    _, done = left_corner_chart(productions, start, w)
    return len(w) in done[(start, 0)]                                    # @line L13
test_left_corner_parsing.py 107 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/left-corner-parsing/. The authors are listed in prov/P8PFGP.jsonl.

import itertools
import random

from left_corner_parsing import left_corner_chart, left_corner_closure, left_corner_recognize

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


def derives(productions, start, w):
    """Independent oracle: least fixpoint of 'A derives w[i:j]' over all spans."""
    nts = {a for a, _ in productions} | {start}
    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 random_grammar(rng):
    nts = ["S", "A", "B"][: rng.randint(1, 3)]
    syms = nts + ["a", "b"]
    prods = []
    for a in nts:
        for _ in range(rng.randint(1, 3)):
            k = rng.choice([0, 1, 1, 2, 2, 3])
            prods.append((a, tuple(rng.choice(syms) for _ in range(k))))
    if rng.random() < 0.3:                      # left recursion
        prods.append(("S", ("S", rng.choice(syms))))
    if rng.random() < 0.2:                      # hidden left recursion and cycles
        prods.append(("S", (rng.choice(nts), "S")))
    return prods


def test_example_chart():
    w = ["a", "+", "a"]
    chart, done = left_corner_chart(EXAMPLE, "E", w)
    show = [sorted((c, i, p, d) for c, i, p, d in col) for col in chart]
    assert show[0] == []
    assert show[1] == [("E", 0, 0, 1), ("E", 0, 1, 1), ("E", 0, 2, 1)]
    assert show[2] == [("E", 0, 0, 2)]
    assert show[3] == [("E", 0, 0, 1), ("E", 0, 0, 3), ("T", 2, 2, 1)]
    assert done == {("E", 0): {1, 3}, ("T", 2): {3}}
    assert left_corner_closure(EXAMPLE, "E") == {"E": {"E", "T"}, "T": {"T"}}
    assert left_corner_recognize(EXAMPLE, "E", w)
    assert not left_corner_recognize(EXAMPLE, "E", ["a", "+"])


def test_empty_input_and_epsilon():
    g = [("S", ()), ("S", ("A", "S")), ("A", ())]
    assert left_corner_recognize(g, "S", [])
    assert not left_corner_recognize(g, "S", ["a"])
    g2 = [("S", ("A", "A", "a")), ("A", ())]
    assert left_corner_recognize(g2, "S", ["a"])
    assert not left_corner_recognize(g2, "S", [])


def test_cycles_units_and_no_productions():
    g = [("S", ("S",)), ("S", ("A",)), ("A", ("S",)), ("A", ("a",)), ("S", ("S", "S"))]
    for k in range(5):
        assert left_corner_recognize(g, "S", ["a"] * k) == (k >= 1)
    assert not left_corner_recognize([("A", ("a",))], "S", ["a"])
    assert not left_corner_recognize([], "S", [])


def test_random_against_oracle():
    rng = random.Random(20261106)
    inputs = 0
    for _ in range(300):
        g = random_grammar(rng)
        words = [list(t) for m in range(4) for t in itertools.product("ab", repeat=m)]
        words += [[rng.choice("ab") for _ in range(rng.randint(4, 7))] for _ in range(3)]
        for w in words:
            assert left_corner_recognize(g, "S", w) == derives(g, "S", w), (g, w)
            inputs += 1
    assert inputs >= 500


def test_long_inputs_no_recursion_limit():
    right = [("S", ("a", "S")), ("S", ("b",))]
    left = [("S", ("S", "a")), ("S", ("b",))]
    n = 3000
    assert left_corner_recognize(right, "S", ["a"] * n + ["b"])
    assert not left_corner_recognize(right, "S", ["a"] * n)
    assert left_corner_recognize(left, "S", ["b"] + ["a"] * n)

What the next level needs

  • the tests did not run in this build

Read the full page about Left-corner parsing