Algorithm Search⌘K

Incremental parsing in Python

Re-parses only the changed regions of an edited input, reusing prior parse results.

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

"""Incremental Earley recognition: reparse an edited string and reuse the old chart.

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. An item (p, dot, origin) stands for [A -> alpha . beta, origin], where
productions[p] = (A, alpha beta) and len(alpha) = dot. A chart is a list of
item sets E[0], ..., E[n], one for each position of the input.

An edit (p, q, v) replaces the tokens w[p:q] by the tuple v. The sets E[0..p]
do not change. The sets after the edit are computed again until a set is
"anchored": it equals the old set with origins shifted, and every origin it
uses is anchored or before the edit. From there on, the old sets are shifted
and reused.
"""

from typing import Iterable, Sequence

Production = tuple[str, tuple[str, ...]]
Item = tuple[int, int, int]
Chart = list[frozenset[Item]]


class Grammar:
    """A grammar with the tables that Earley sets need."""

    def __init__(self, productions: Sequence[Production], start: str) -> None:
        self.productions = list(productions)
        self.start = start
        self.nts = {a for a, _ in self.productions}
        self.by_lhs: dict[str, list[int]] = {}
        for i, (a, _) in enumerate(self.productions):
            self.by_lhs.setdefault(a, []).append(i)
        self.nullable: set[str] = set()
        changed = True
        while changed:
            changed = False
            for a, rhs in self.productions:
                if a not in self.nullable and all(x in self.nullable for x in rhs):
                    self.nullable.add(a)
                    changed = True


def earley_set(g: Grammar, chart: Sequence[frozenset[Item]], seed: Iterable[Item], j: int) -> frozenset[Item]:
    """Return E[j]: the closure of seed under prediction and completion.

    chart[0..j-1] are the earlier sets; completion with origin i < j reads
    chart[i], through an index by the symbol after the dot that is built once
    for each such i. The nullable rule of Aycock and Horspool makes one pass
    enough."""
    items: list[Item] = []
    seen: set[Item] = set()
    waiting: dict[str, list[Item]] = {}
    index: dict[int, dict[str, list[Item]]] = {}

    def add(item: Item) -> None:
        if item in seen:
            return
        seen.add(item)
        items.append(item)
        p, dot, _ = item
        rhs = g.productions[p][1]
        if dot < len(rhs):
            waiting.setdefault(rhs[dot], []).append(item)

    for item in seed:
        add(item)
    predicted: set[str] = set()
    completed: set[tuple[str, int]] = set()
    k = 0
    while k < len(items):
        p, dot, origin = items[k]
        k += 1
        lhs, rhs = g.productions[p]
        if dot == len(rhs):
            if (lhs, origin) in completed:
                continue
            completed.add((lhs, origin))
            if origin == j:
                waiters = list(waiting.get(lhs, []))
            else:
                if origin not in index:
                    table: dict[str, list[Item]] = {}
                    for it in chart[origin]:
                        x = _after(g, it)
                        if x is not None:
                            table.setdefault(x, []).append(it)
                    index[origin] = table
                waiters = index[origin].get(lhs, [])
            for q, qdot, qorig in waiters:
                add((q, qdot + 1, qorig))
        elif rhs[dot] in g.nts:
            x = rhs[dot]
            if x not in predicted:
                predicted.add(x)
                for q in g.by_lhs[x]:
                    add((q, 0, j))
            if x in g.nullable:
                add((p, dot + 1, origin))
    return frozenset(items)


def _after(g: Grammar, item: Item) -> str | None:
    p, dot, _ = item
    rhs = g.productions[p][1]
    return rhs[dot] if dot < len(rhs) else None


def scan_seed(g: Grammar, prev: frozenset[Item], a: str) -> list[Item]:
    """Items of prev with the terminal a after the dot, with the dot moved past a."""
    out = []
    for p, dot, origin in prev:
        rhs = g.productions[p][1]
        if dot < len(rhs) and rhs[dot] == a and rhs[dot] not in g.nts:
            out.append((p, dot + 1, origin))
    return out


def earley_chart(g: Grammar, w: Sequence[str]) -> Chart:
    """The full Earley chart of w, computed from scratch."""
    chart: Chart = []
    seed = [(p, 0, 0) for p in g.by_lhs.get(g.start, [])]
    for j in range(len(w) + 1):
        if j > 0:
            seed = scan_seed(g, chart[j - 1], w[j - 1])
        chart.append(earley_set(g, chart, seed, j))
    return chart


def accepts(g: Grammar, chart: Chart) -> bool:
    """True when some [S -> alpha ., 0] is in the last set."""
    for p, dot, origin in chart[-1]:
        a, rhs = g.productions[p]
        if a == g.start and dot == len(rhs) and origin == 0:
            return True
    return False


def reparse(g: Grammar, w: Sequence[str], chart: Chart, p: int, q: int,
            v: Sequence[str]) -> tuple[list[str], Chart, int]:
    """Replace w[p:q] by v. Return (the new string, its chart, the number of
    sets computed again). chart must be the Earley chart of w."""
    n = len(w)
    if not 0 <= p <= q <= n:
        raise ValueError("need 0 <= p <= q <= len(w)")
    w2 = list(w[:p]) + list(v) + list(w[q:])                          # @line L1
    n2 = len(w2)
    delta = len(v) - (q - p)
    q2 = p + len(v)

    def shift_origin(i: int) -> int:
        return i + delta if i >= q else i

    def shift(old: frozenset[Item]) -> frozenset[Item]:
        return frozenset((pp, dot, shift_origin(i)) for pp, dot, i in old)

    new: Chart = list(chart[:p + 1])                                  # @line L2
    anchored: dict[int, bool] = {}
    recomputed = 0
    for k in range(p, n2 + 1):                                        # @line L3
        if k > p:                                                     # @line L4
            new.append(earley_set(g, new, scan_seed(g, new[k - 1], w2[k - 1]), k))
            recomputed += 1
        if k >= q2:                                                   # @line L5
            old = chart[k - delta]
            ok = all((i < q and i <= p) or                            # @line L6
                     (i >= q and (i == k - delta or anchored[i + delta]))
                     for _, _, i in old)
            ok = ok and new[k] == shift(old)                          # @line L7
            anchored[k] = ok
            if ok:                                                    # @line L8
                for j in range(k + 1, n2 + 1):                        # @line L9
                    new.append(shift(chart[j - delta]))
                return w2, new, recomputed                            # @line L10
    return w2, new, recomputed                                        # @line L11


def recognize_after_edit(g: Grammar, w: Sequence[str], chart: Chart, p: int, q: int,
                         v: Sequence[str]) -> bool:
    """True when the start symbol derives the edited string."""
    _, new, _ = reparse(g, w, chart, p, q, v)
    return accepts(g, new)                                            # @line L12
test_incremental_parsing.py 166 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/incremental-parsing/. The authors are listed in prov/3Y0Y57.jsonl.

"""Tests for incremental Earley recognition against a full reparse and a
brute-force derivation oracle."""

import random

from incremental_parsing import (Grammar, accepts, earley_chart, recognize_after_edit,
                                 reparse)

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


def derives(productions, start, w):
    """Brute-force oracle: the least set of (A, i, j) with A =>* w[i:j],
    found by iterating to a fixed point over all spans."""
    n = len(w)
    nts = {a for a, _ in productions}
    known = set()
    changed = True
    while changed:
        changed = False
        for a, rhs in productions:
            for i in range(n + 1):
                ends = {i}
                for x in rhs:
                    nxt = set()
                    for e in ends:
                        if x in nts:
                            nxt.update(f for f in range(e, n + 1) if (x, e, f) in known)
                        elif e < n and w[e] == x:
                            nxt.add(e + 1)
                    ends = nxt
                for j in ends:
                    if (a, i, j) not in known:
                        known.add((a, i, j))
                        changed = True
    return (start, 0, n) in known


def random_grammar(rng, letters):
    names = ["S", "A", "B"][: rng.randint(1, 3)]
    prods = []
    for a in names:
        for _ in range(rng.randint(1, 3)):
            r = rng.random()
            if r < 0.15:
                rhs = ()
            elif r < 0.3:
                rhs = (rng.choice(names),)
            elif r < 0.45:
                rhs = (a,) + tuple(rng.choice(names + letters) for _ in range(rng.randint(1, 2)))
            else:
                rhs = tuple(rng.choice(names + letters) for _ in range(rng.randint(1, 3)))
            if (a, rhs) not in prods:
                prods.append((a, rhs))
    return prods


def test_example_from_the_page():
    g = Grammar(EXPR, "E")
    w = list("a+(a)+a")
    chart = earley_chart(g, w)
    w2, new, recomputed = reparse(g, w, chart, 3, 4, list("a+a"))
    assert "".join(w2) == "a+(a+a)+a"
    assert recomputed == 4
    assert new == earley_chart(g, w2)
    assert new[:4] == chart[:4]
    assert accepts(g, new)


def test_equal_sets_with_a_changed_origin_do_not_anchor():
    # After the edit x -> y, the sets after "a" are equal to the old ones,
    # but their origin 1 points to a set that changed, so they must not be reused.
    prods = [("S", ("x", "A", "c")), ("S", ("y", "A", "d")), ("A", ("a", "A")), ("A", ("z",))]
    g = Grammar(prods, "S")
    w = list("xaazc")
    chart = earley_chart(g, w)
    assert accepts(g, chart)
    w2, new, recomputed = reparse(g, w, chart, 0, 1, ["y"])
    assert new[2] == chart[2]
    assert recomputed == 5
    assert not accepts(g, new)
    assert new == earley_chart(g, w2)


def test_insertion_shifts_the_origin_at_the_edit():
    g = Grammar(EXPR, "E")
    w = list("a+(a)")
    chart = earley_chart(g, w)
    w2, new, recomputed = reparse(g, w, chart, 2, 2, list("a+"))
    assert "".join(w2) == "a+a+(a)"
    assert recomputed == 2
    assert new == earley_chart(g, w2)


def test_empty_input_and_empty_edits():
    g = Grammar([("S", ()), ("S", ("S", "a"))], "S")
    chart = earley_chart(g, [])
    assert accepts(g, chart)
    w2, new, _ = reparse(g, [], chart, 0, 0, [])
    assert w2 == [] and new == chart
    w2, new, _ = reparse(g, [], chart, 0, 0, ["a", "a"])
    assert new == earley_chart(g, w2) and accepts(g, new)
    w2, new2, _ = reparse(g, w2, new, 0, 2, [])
    assert w2 == [] and new2 == chart


def test_random_edits_match_full_reparse_and_oracle():
    rng = random.Random(20261106)
    cases = 0
    reused = 0
    accepted = 0
    for _ in range(700):
        letters = rng.choice([["a"], ["a", "b"]])
        prods = random_grammar(rng, letters)
        g = Grammar(prods, "S")
        w = [rng.choice(letters) for _ in range(rng.randint(0, 6))]
        chart = earley_chart(g, w)
        for _ in range(3):
            p = rng.randint(0, len(w))
            q = rng.randint(p, len(w))
            v = [rng.choice(letters) for _ in range(rng.randint(0, 3))]
            w2, new, recomputed = reparse(g, w, chart, p, q, v)
            assert w2 == w[:p] + v + w[q:]
            assert new == earley_chart(g, w2)
            assert accepts(g, new) == derives(prods, "S", w2)
            assert recognize_after_edit(g, w, chart, p, q, v) == accepts(g, new)
            reused += recomputed < len(w2) - p
            accepted += accepts(g, new)
            cases += 1
            w, chart = w2, new
    assert cases >= 2000
    assert reused > 500
    assert accepted > 250


def test_ambiguous_and_cyclic_grammars():
    rng = random.Random(7)
    grammars = [
        [("S", ("S", "S")), ("S", ("a",)), ("S", ())],
        [("S", ("A",)), ("A", ("S",)), ("A", ("a",)), ("S", ("S", "b"))],
        [("S", ("A", "S", "B")), ("S", ()), ("A", ("a",)), ("A", ()), ("B", ("b",))],
    ]
    for prods in grammars:
        g = Grammar(prods, "S")
        for _ in range(60):
            w = [rng.choice("ab") for _ in range(rng.randint(0, 7))]
            chart = earley_chart(g, w)
            p = rng.randint(0, len(w))
            q = rng.randint(p, len(w))
            v = [rng.choice("ab") for _ in range(rng.randint(0, 3))]
            w2, new, _ = reparse(g, w, chart, p, q, v)
            assert new == earley_chart(g, w2)
            assert accepts(g, new) == derives(prods, "S", w2)


def test_long_input_reuses_the_suffix():
    g = Grammar(EXPR, "E")
    w = list("a" + "+a" * 1500)
    chart = earley_chart(g, w)
    w2, new, recomputed = reparse(g, w, chart, 1000, 1001, list("(a+a)"))
    assert recomputed <= 6
    assert accepts(g, new)
    assert new == earley_chart(g, w2)

What the next level needs

  • the tests did not run in this build

Read the full page about Incremental parsing