Algorithm Search⌘K

Antimirov partial derivatives in Python

Builds an NFA with at most k+1 states for a regex with k symbol occurrences, whose states are partial derivatives.

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

"""Antimirov partial derivatives and the partial derivative automaton.

A regular expression is a tuple:
("empty",) for the empty language, ("eps",) for the empty string,
("sym", a) for one symbol, ("alt", r, s), ("cat", r, s) and ("star", r).

Terms are hash-consed: each distinct term is a small integer, so equal
terms are shared and a set of terms is a frozenset of integers. The
partial derivative of a term by a symbol is a set of terms, and the terms
reachable from the input expression are the states of an NFA with at most
k + 1 states, where k is the number of symbol occurrences.
"""

from __future__ import annotations

from typing import Any

Regex = tuple[Any, ...]

EMPTY, EPS, SYM, ALT, CAT, STAR = range(6)
_KIND = {"empty": EMPTY, "eps": EPS, "sym": SYM, "alt": ALT, "cat": CAT, "star": STAR}


class Terms:
    """A store of hash-consed terms with memoized nullability and partial derivatives."""

    def __init__(self) -> None:
        self.node: list[tuple[Any, ...]] = []
        self.nullable: list[bool] = []
        self._ids: dict[tuple[Any, ...], int] = {}
        self._memo: dict[tuple[int, str], frozenset[int]] = {}
        self.empty = self._make((EMPTY,))
        self.eps = self._make((EPS,))

    def _make(self, key: tuple[Any, ...]) -> int:
        i = self._ids.get(key)
        if i is not None:
            return i
        kind = key[0]
        if kind == EMPTY or kind == SYM:
            nul = False
        elif kind == EPS or kind == STAR:
            nul = True
        elif kind == ALT:
            nul = self.nullable[key[1]] or self.nullable[key[2]]
        else:
            nul = self.nullable[key[1]] and self.nullable[key[2]]
        i = len(self.node)
        self.node.append(key)
        self.nullable.append(nul)
        self._ids[key] = i
        return i

    def intern(self, regex: Regex) -> int:
        """The id of a tuple regex, built bottom-up with an explicit stack."""
        out: list[int] = []
        todo: list[tuple[Regex, bool]] = [(regex, False)]
        while todo:
            r, ready = todo.pop()
            kind = _KIND.get(r[0])
            if kind is None:
                raise ValueError(f"unknown node {r!r}")
            if kind in (ALT, CAT, STAR) and not ready:
                todo.append((r, True))
                for child in reversed(r[1:]):
                    todo.append((child, False))
                continue
            if kind == EMPTY:
                out.append(self.empty)
            elif kind == EPS:
                out.append(self.eps)
            elif kind == SYM:
                out.append(self._make((SYM, r[1])))
            elif kind == STAR:
                out.append(self._make((STAR, out.pop())))
            else:
                right = out.pop()
                left = out.pop()
                out.append(self._make((kind, left, right)))
        return out.pop()

    def odot(self, terms: frozenset[int], t: int) -> frozenset[int]:
        """S ⊙ t: concatenate t to each term of S, with ε t = t, S ∅ = ∅ and S ε = S."""
        if t == self.empty:  # @line L14
            return frozenset()
        if t == self.eps:
            return terms
        return frozenset(t if z == self.eps else self._make((CAT, z, t)) for z in terms)

    def partial(self, x: int, a: str) -> frozenset[int]:
        """The set of partial derivatives of term x by the symbol a (memoized, no recursion)."""
        memo = self._memo
        if (x, a) in memo:
            return memo[(x, a)]
        stack = [x]
        while stack:
            y = stack[-1]
            if (y, a) in memo:
                stack.pop()
                continue
            node = self.node[y]
            kind = node[0]
            if kind == EMPTY or kind == EPS:  # @line L9
                result: frozenset[int] | None = frozenset()
            elif kind == SYM:  # @line L10
                result = frozenset([self.eps]) if node[1] == a else frozenset()
            elif kind == ALT:  # @line L11
                need = [c for c in node[1:] if (c, a) not in memo]
                result = None if need else memo[(node[1], a)] | memo[(node[2], a)]
            elif kind == CAT:  # @line L12
                s, t = node[1], node[2]
                need = [c for c in ([s, t] if self.nullable[s] else [s]) if (c, a) not in memo]
                if need:
                    result = None
                else:
                    result = self.odot(memo[(s, a)], t)
                    if self.nullable[s]:
                        result = result | memo[(t, a)]
            else:  # @line L13
                need = [] if (node[1], a) in memo else [node[1]]
                result = None if need else self.odot(memo[(node[1], a)], y)
            if result is None:
                stack.extend(need)
            else:
                memo[(y, a)] = result
                stack.pop()
        return memo[(x, a)]

    def symbols(self, x: int) -> list[str]:
        """The distinct symbols that occur in term x, sorted."""
        seen = {x}
        stack = [x]
        out: set[str] = set()
        while stack:
            node = self.node[stack.pop()]
            if node[0] == SYM:
                out.add(node[1])
            elif node[0] in (ALT, CAT, STAR):
                for c in node[1:]:
                    if c not in seen:
                        seen.add(c)
                        stack.append(c)
        return sorted(out)

    def show(self, x: int) -> str:
        """The term x in this site's notation, with parentheses where needed."""
        prec = {ALT: 0, CAT: 1, STAR: 2}
        out: list[str] = []
        todo: list[Any] = [(x, 0)]
        while todo:
            item = todo.pop()
            if isinstance(item, str):
                out.append(item)
                continue
            y, ctx = item
            node = self.node[y]
            kind = node[0]
            if kind == EMPTY:
                out.append("∅")
                continue
            if kind == EPS:
                out.append("ε")
                continue
            if kind == SYM:
                out.append(node[1])
                continue
            p = prec[kind]
            parts: list[Any] = []
            if p < ctx:
                parts.append("(")
            if kind == ALT:
                parts += [(node[1], 0), " | ", (node[2], 0)]
            elif kind == CAT:
                parts += [(node[1], 1), (node[2], 2)]
            else:
                parts += [(node[1], 3), "*"]
            if p < ctx:
                parts.append(")")
            todo.extend(reversed(parts))
        return "".join(out)


class PDAutomaton:
    """The partial derivative automaton of a regular expression."""

    def __init__(self, regex: Regex) -> None:
        self.terms = T = Terms()
        self.start = T.intern(regex)
        self.alphabet = T.symbols(self.start)
        self.states: list[int] = [self.start]  # @line L1
        self.delta: dict[int, dict[str, frozenset[int]]] = {}
        seen = {self.start}
        todo = [self.start]
        while todo:  # @line L2
            x = todo.pop()  # @line L3
            row: dict[str, frozenset[int]] = {}
            for a in self.alphabet:  # @line L4
                targets = T.partial(x, a)  # @line L5
                if targets:
                    row[a] = targets  # @line L6
                for y in targets:
                    if y not in seen:  # @line L7
                        seen.add(y)
                        self.states.append(y)
                        todo.append(y)
            self.delta[x] = row
        self.final = frozenset(x for x in self.states if T.nullable[x])  # @line L8

    def transitions(self) -> int:
        """The number of moves (x, a, y)."""
        return sum(len(ys) for row in self.delta.values() for ys in row.values())

    def accepts(self, word: str) -> bool:
        """Decide whether word is in the language, by running the NFA on sets of states."""
        current = {self.start}  # @line L16
        for c in word:  # @line L17
            nxt: set[int] = set()
            for x in current:
                nxt |= self.delta[x].get(c, frozenset())
            current = nxt
            if not current:
                return False
        return not self.final.isdisjoint(current)  # @line L18

    def accepts_from(self, x: int, word: str) -> bool:
        """Decide whether word is in the language of the state x."""
        current = {x}
        for c in word:
            nxt: set[int] = set()
            for y in current:
                nxt |= self.delta[y].get(c, frozenset())
            current = nxt
        return not self.final.isdisjoint(current)


def symbol_occurrences(regex: Regex) -> int:
    """k, the number of symbol leaves of the tuple regex."""
    count = 0
    stack = [regex]
    while stack:
        r = stack.pop()
        if r[0] == "sym":
            count += 1
        elif r[0] in ("alt", "cat", "star"):
            stack.extend(r[1:])
    return count


def antimirov_match(regex: Regex, word: str) -> bool:
    """Decide whether word is in L(regex) with the partial derivative automaton."""
    automaton = PDAutomaton(regex)  # @line L15
    return automaton.accepts(word)
test_antimirov_derivatives.py 227 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/antimirov-derivatives/. The authors are listed in prov/B0378X.jsonl.

import itertools
import random

from antimirov_derivatives import PDAutomaton, antimirov_match, symbol_occurrences

EMPTY = ("empty",)
EPS = ("eps",)


def parse(text):
    """Parse | , concatenation, *, parentheses, ε and ∅ into the tuple form."""
    pos = 0

    def peek():
        return text[pos] if pos < len(text) else None

    def p_alt():
        nonlocal pos
        r = p_cat()
        while peek() == "|":
            pos += 1
            r = ("alt", r, p_cat())
        return r

    def p_cat():
        items = []
        while peek() is not None and peek() not in "|)":
            items.append(p_rep())
        if not items:
            return EPS
        r = items[0]
        for x in items[1:]:
            r = ("cat", r, x)
        return r

    def p_rep():
        nonlocal pos
        r = p_atom()
        while peek() == "*":
            pos += 1
            r = ("star", r)
        return r

    def p_atom():
        nonlocal pos
        c = peek()
        pos += 1
        if c == "(":
            r = p_alt()
            assert peek() == ")"
            pos += 1
            return r
        if c == "ε":
            return EPS
        if c == "∅":
            return EMPTY
        return ("sym", c)

    r = p_alt()
    assert pos == len(text)
    return r


def language(r, bound):
    """All strings of L(r) of length at most bound, by enumeration."""
    t = r[0]
    if t == "empty":
        return set()
    if t == "eps":
        return {""}
    if t == "sym":
        return {r[1]} if bound >= 1 else set()
    if t == "alt":
        return language(r[1], bound) | language(r[2], bound)
    if t == "cat":
        a, b = language(r[1], bound), language(r[2], bound)
        return {x + y for x in a for y in b if len(x) + len(y) <= bound}
    body = language(r[1], bound) - {""}
    result, frontier = {""}, {""}
    while frontier:
        frontier = {x + y for x in frontier for y in body if len(x) + len(y) <= bound} - result
        result |= frontier
    return result


def words(alphabet, bound):
    for k in range(bound + 1):
        for t in itertools.product(alphabet, repeat=k):
            yield "".join(t)


def random_regex(rng, alphabet, depth):
    if depth == 0 or rng.random() < 0.25:
        x = rng.random()
        if x < 0.08:
            return EMPTY
        if x < 0.18:
            return EPS
        return ("sym", rng.choice(alphabet))
    k = rng.random()
    if k < 0.35:
        return ("alt", random_regex(rng, alphabet, depth - 1), random_regex(rng, alphabet, depth - 1))
    if k < 0.75:
        return ("cat", random_regex(rng, alphabet, depth - 1), random_regex(rng, alphabet, depth - 1))
    return ("star", random_regex(rng, alphabet, depth - 1))


def names(a, xs):
    return sorted(a.terms.show(x) for x in xs)


def test_example():
    a = PDAutomaton(parse("(a|b)*ab"))
    t = a.terms
    r = a.start
    assert names(a, a.states) == ["(a | b)*ab", "b", "ε"]
    assert names(a, t.partial(r, "a")) == ["(a | b)*ab", "b"]
    assert names(a, t.partial(r, "b")) == ["(a | b)*ab"]
    assert names(a, a.final) == ["ε"]
    assert a.transitions() == 4
    assert [w for w in words("ab", 4) if a.accepts(w)] == ["ab", "aab", "bab", "aaab", "abab", "baab", "bbab"]


def test_bound_is_reached():
    a = PDAutomaton(parse("(a|ε)(a|ε)a"))
    assert len(a.states) == symbol_occurrences(parse("(a|ε)(a|ε)a")) + 1 == 4


def test_quadratic_transitions():
    for n in range(1, 8):
        letters = "abcdefg"[:n]
        a = PDAutomaton(parse("".join(c + "*" for c in letters)))
        assert len(a.states) == n
        assert a.transitions() == n * (n + 1) // 2


def test_edge_cases():
    assert not antimirov_match(EMPTY, "")
    assert antimirov_match(EPS, "")
    assert not antimirov_match(EPS, "a")
    assert antimirov_match(parse("a*"), "")
    assert antimirov_match(parse("((a*)*)*"), "aaa")
    assert not antimirov_match(parse("a∅"), "a")
    assert not antimirov_match(parse("∅*a"), "")
    assert antimirov_match(parse("∅*a"), "a")
    assert not antimirov_match(parse("ab"), "abc")
    assert len(PDAutomaton(parse("a∅|b")).states) <= 3
    assert len(PDAutomaton(EMPTY).states) == 1


def test_deep_inputs():
    n = 4000
    r = ("sym", "a")
    for _ in range(n - 1):
        r = ("cat", r, ("sym", "a"))
    assert antimirov_match(r, "a" * n)
    assert not antimirov_match(r, "a" * (n - 1))
    s = ("sym", "a")
    for _ in range(n):
        s = ("star", s)
    assert antimirov_match(s, "aaa")
    u = ("sym", "b")
    for _ in range(n):
        u = ("alt", ("sym", "a"), u)
    assert antimirov_match(u, "b")
    assert len(PDAutomaton(u).states) == 2


def test_random_against_enumeration():
    rng = random.Random(1996)
    cases = 0
    for alphabet, bound in (("ab", 6), ("abc", 4)):
        for _ in range(400):
            r = random_regex(rng, alphabet, rng.randint(0, 5))
            want = language(r, bound)
            a = PDAutomaton(r)
            k = symbol_occurrences(r)
            assert len(a.states) <= k + 1
            for w in words(alphabet, bound):
                assert a.accepts(w) == (w in want), (r, w)
            cases += 1
    assert cases >= 500


def test_states_denote_quotients():
    rng = random.Random(7)
    for _ in range(300):
        alphabet = rng.choice(["ab", "abc"])
        bound = 5 if alphabet == "ab" else 3
        r = random_regex(rng, alphabet, rng.randint(0, 4))
        a = PDAutomaton(r)
        lang = language(r, bound + 1)
        for x in a.states:
            for c in alphabet:
                union = set()
                for y in a.terms.partial(x, c):
                    union |= {w for w in words(alphabet, bound) if a.accepts_from(y, w)}
                from_x = {w for w in words(alphabet, bound + 1) if a.accepts_from(x, w)}
                assert union == {w[1:] for w in from_x if w[:1] == c and len(w) <= bound + 1}
        assert {w for w in words(alphabet, bound + 1) if a.accepts_from(a.start, w)} == lang


def as_tuple(terms, x):
    node = terms.node[x]
    kind = ("empty", "eps", "sym", "alt", "cat", "star")[node[0]]
    if kind in ("empty", "eps"):
        return (kind,)
    if kind == "sym":
        return ("sym", node[1])
    return (kind,) + tuple(as_tuple(terms, c) for c in node[1:])


def test_each_state_against_its_term():
    rng = random.Random(11)
    for _ in range(300):
        alphabet = rng.choice(["ab", "abc"])
        bound = 5 if alphabet == "ab" else 3
        r = random_regex(rng, alphabet, rng.randint(0, 5))
        a = PDAutomaton(r)
        for x in a.states:
            want = language(as_tuple(a.terms, x), bound)
            got = {w for w in words(alphabet, bound) if a.accepts_from(x, w)}
            assert got == want
            assert (x in a.final) == ("" in want)

What the next level needs

  • the tests did not run in this build

Read the full page about Antimirov partial derivatives