Final Project

What are we building in the final project? You will develop a package for parsing with multiple context-free grammars (MCFGs). The package must implement an agenda-based parser as described in Shieber et al. 1995, using the inference rules laid out in Kallmeyer 2013.

In addition to a working agenda-based parser, the package must use the standard directory structure for a Python package, including a full test suite implemented in pytest. If you have not written a Python package before, I encourage you to read this tutorial and use the directory structure they discuss:

PACKAGE_NAME/
├── LICENSE
├── pyproject.toml
├── README.md
├── src/
│   └── PACKAGE_NAME/
│       ├── __init__.py
│       └── example.py
└── tests/

Test Grammar

In writing tests, it will be useful to have a grammar to test your parser against. Rather than have you write your own, you might find it useful to use the one below.

S(uv) -> NP(u) VP(v)
S(uv) -> NPwh(u) VP(v)
S(vuw) -> Aux(u) Swhmain(v, w)
S(uwv) -> NPdisloc(u, v) VP(w)
S(uwv) -> NPwhdisloc(u, v) VP(w)
Sbar(uv) -> C(u) S(v)
Sbarwh(v, uw) -> C(u) Swhemb(v, w)
Sbarwh(u, v) -> NPwh(u) VP(v)
Swhmain(v, uw) -> NP(u) VPwhmain(v, w)
Swhmain(w, uxv) -> NPdisloc(u, v) VPwhmain(w, x)
Swhemb(v, uw) -> NP(u) VPwhemb(v, w)
Swhemb(w, uxv) -> NPdisloc(u, v) VPwhemb(w, x)
Src(v, uw) -> NP(u) VPrc(v, w)
Src(w, uxv) -> NPdisloc(u, v) VPrc(w, x)
Src(u, v) -> N(u) VP(v)
Swhrc(u, v) -> Nwh(u) VP(v)
Swhrc(v, uw) -> NP(u) VPwhrc(v, w)
Sbarwhrc(v, uw) -> C(u) Swhrc(v, w)
VP(uv) -> Vpres(u) NP(v)
VP(uv) -> Vpres(u) Sbar(v)
VPwhmain(u, v) -> NPwh(u) Vroot(v)
VPwhmain(u, wv) -> NPwhdisloc(u, v) Vroot(w)
VPwhmain(v, uw) -> Vroot(u) Sbarwh(v, w)
VPwhemb(u, v) -> NPwh(u) Vpres(v)
VPwhemb(u, wv) -> NPwhdisloc(u, v) Vpres(w)
VPwhemb(v, uw) -> Vpres(u) Sbarwh(v, w)
VPrc(u, v) -> N(u) Vpres(v)
VPrc(v, uw) -> Vpres(u) Nrc(v, w)
VPwhrc(u, v) -> Nwh(u) Vpres(v)
VPwhrc(v, uw) -> Vpres(u) Sbarwhrc(v, w)
NP(uv) -> D(u) N(v)
NP(uvw) -> D(u) Nrc(v, w)
NPdisloc(uv, w) -> D(u) Nrc(v, w)
NPwh(uv) -> Dwh(u) N(v)
NPwh(uvw) -> Dwh(u) Nrc(v, w)
NPwhdisloc(uv, w) -> Dwh(u) Nrc(v, w)
Nrc(v, uw) -> C(u) Src(v, w)
Nrc(u, vw) -> N(u) Swhrc(v, w)
Nrc(u, vwx) -> Nrc(u, v) Swhrc(w, x)
Dwh(which)
Nwh(who)
D(the)
D(a)
N(greyhound)
N(human)
Vpres(believes)
Vroot(believe)
Aux(does)
C(that)

Whichever test grammar you use, load it as a pytest.fixture so that the tests can share one checked grammar object.

Rules

We begin with the rule representation. A context-free Rule from Assignments 9 and 10 has a string-valued left side and a tuple-valued right side. Here, an MCFGRule instead has one MCFGRuleElement on the left and a tuple of MCFGRuleElement objects on the right. Each element records both a nonterminal—e.g. S, NP, or VP—and its string component variables.

import re

type StringVariable = int | str
type StringVariables = tuple[StringVariable, ...]


class MCFGRuleElement:
    """A multiple context free grammar rule element.

    Parameters
    ----------
    variable : str
        The nonterminal variable name.
    string_variables : StringVariables
        Variable number of string variable tuples.

    Attributes
    ----------
    variable : str
        The nonterminal variable name.
    string_variables : tuple[StringVariables, ...]
        The string variable tuples.
    """

    def __init__(self, variable: str, *string_variables: StringVariables) -> None:
        self._variable = variable
        self._string_variables = string_variables

    def __str__(self) -> str:
        strvars = ", ".join(
            "".join(str(v) for v in vtup) for vtup in self._string_variables
        )

        return f"{self._variable}({strvars})"

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, MCFGRuleElement):
            return NotImplemented
        vareq = self._variable == other._variable
        strvareq = self._string_variables == other._string_variables

        return vareq and strvareq

    def to_tuple(self) -> tuple[str, tuple[StringVariables, ...]]:
        """Convert to a hashable tuple representation.

        Returns
        -------
        tuple[str, tuple[StringVariables, ...]]
            The (variable, string_variables) tuple.
        """
        return (self._variable, self._string_variables)

    def __hash__(self) -> int:
        return hash(self.to_tuple())

    @property
    def variable(self) -> str:
        """The nonterminal variable name."""
        return self._variable

    @property
    def string_variables(self) -> tuple[StringVariables, ...]:
        """The string variable tuples."""
        return self._string_variables

    @property
    def unique_string_variables(self) -> set[int]:
        """The unique string variable indices across all tuples."""
        return {
            variable
            for component in self.string_variables
            for variable in component
            if isinstance(variable, int)
        }

An MCFGRuleElement wraps a nonterminal name and its string components. In a nonterminal rule, the components contain integer indices. For instance, VPwhemb(u, v) -> NPwh(u) Vpres(v) may represent u and v by the singleton tuples (0,) and (1,). A terminal rule such as Dwh(which) uses the same representation with the constant string "which"; MCFGRule.string_yield exposes that constant. This starter representation is deliberately nonerasing: every nonterminal has at least one nonempty component, and every terminal rule yields exactly one nonempty token. The supplied grammar satisfies those restrictions.

print(
    MCFGRuleElement("VPwhemb", (0,), (1,)),
    "->",
    MCFGRuleElement("NPwh", (0,)),
    MCFGRuleElement("Vpres", (1,)),
)

Why is one component represented by a tuple rather than one integer? A left-side component may concatenate several right-side variables. In VPwhemb(u, wv) -> NPwhdisloc(u, v) Vpres(w), the second output component contains w followed by v. Its index tuple must thus contain both variables in that order.

print(
    MCFGRuleElement("VPwhemb", (0,), (2, 1)),
    "->",
    MCFGRuleElement("NPwhdisloc", (0,), (1,)),
    MCFGRuleElement("Vpres", (2,)),
)

What must a recognizer record for each component? It must track the component’s span. In who does the greyhound believe, u and v in VPwhmain(u, v) -> NPwh(u) Vroot(v) are instantiated by who at \([0,1)\) and believe at \([4,5)\), respectively. We use half-open spans throughout: the left endpoint is included and the right endpoint is excluded.

type SpanIndices = tuple[int, int]


class MCFGRuleElementInstance:
    """An instantiated multiple context free grammar rule element.

    Parameters
    ----------
    variable : str
        The nonterminal variable name.
    string_spans : SpanIndices
        Variable number of span index tuples.

    Attributes
    ----------
    variable : str
        The nonterminal variable name.
    string_spans : tuple[SpanIndices, ...]
        The span index tuples.
    """

    def __init__(self, variable: str, *string_spans: SpanIndices) -> None:
        self._variable = variable
        self._string_spans = string_spans

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, MCFGRuleElementInstance):
            return NotImplemented
        vareq = self._variable == other._variable
        strspaneq = self._string_spans == other._string_spans

        return vareq and strspaneq

    def to_tuple(self) -> tuple[str, tuple[SpanIndices, ...]]:
        """Convert to a hashable tuple representation.

        Returns
        -------
        tuple[str, tuple[SpanIndices, ...]]
            The (variable, string_spans) tuple.
        """
        return (self._variable, self._string_spans)

    def __hash__(self) -> int:
        return hash(self.to_tuple())

    def __str__(self) -> str:
        strspans = ", ".join(str(list(stup)) for stup in self._string_spans)

        return f"{self._variable}({strspans})"

    def __repr__(self) -> str:
        return self.__str__()

    @property
    def variable(self) -> str:
        """The nonterminal variable name."""
        return self._variable

    @property
    def string_spans(self) -> tuple[SpanIndices, ...]:
        """The span index tuples."""
        return self._string_spans

We now have enough structure to define the full MCFGRule implementation.

type SpanMap = dict[int, SpanIndices]


class MCFGRule:
    """A linear multiple context free grammar rule.

    Parameters
    ----------
    left_side : MCFGRuleElement
        The left side of the rule.
    right_side : MCFGRuleElement
        Variable number of right side elements.

    Attributes
    ----------
    left_side : MCFGRuleElement
        The left side of the rule.
    right_side : tuple[MCFGRuleElement, ...]
        The right side elements.
    """

    def __init__(
        self, left_side: MCFGRuleElement, *right_side: MCFGRuleElement
    ) -> None:
        self._left_side = left_side
        self._right_side = right_side

        self._validate()

    def to_tuple(self) -> tuple[MCFGRuleElement, tuple[MCFGRuleElement, ...]]:
        """Convert to a hashable tuple representation.

        Returns
        -------
        tuple[MCFGRuleElement, tuple[MCFGRuleElement, ...]]
            The (left_side, right_side) tuple.
        """
        return (self._left_side, self._right_side)

    def __hash__(self) -> int:
        return hash(self.to_tuple())

    def __repr__(self) -> str:
        return "<Rule: " + str(self) + ">"

    def __str__(self) -> str:
        if self.is_terminal:
            return str(self._left_side)

        else:
            return (
                str(self._left_side)
                + " -> "
                + " ".join(str(el) for el in self._right_side)
            )

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, MCFGRule):
            return NotImplemented
        left_side_equal = self._left_side == other._left_side
        right_side_equal = self._right_side == other._right_side

        return left_side_equal and right_side_equal

    def _validate(self) -> None:
        vs = [el.unique_string_variables for el in self.right_side]
        sharing = any(
            vs1.intersection(vs2)
            for i, vs1 in enumerate(vs)
            for j, vs2 in enumerate(vs)
            if i < j
        )

        if sharing:
            raise ValueError("right side variables cannot share string variables")

        if self.is_terminal:
            components = self.left_side.string_variables
            if (
                len(components) != 1
                or len(components[0]) != 1
                or not isinstance(components[0][0], str)
            ):
                raise ValueError(
                    "a terminal rule must contain exactly one string yield"
                )
            return

        if any(
            not component for component in self.left_side.string_variables
        ) or any(
            not element.string_variables
            or any(not component for component in element.string_variables)
            for element in self.right_side
        ):
            raise ValueError(
                "this representation requires nonempty string components"
            )

        left_atoms = [
            variable
            for component in self.left_side.string_variables
            for variable in component
        ]
        right_components = [
            component
            for element in self.right_side
            for component in element.string_variables
        ]
        if any(not isinstance(variable, int) for variable in left_atoms) or any(
            len(component) != 1 or not isinstance(component[0], int)
            for component in right_components
        ):
            raise TypeError(
                "nonterminal rules must use integer string-variable indices"
            )

        right_vars = [component[0] for component in right_components]
        if len(right_vars) != len(set(right_vars)):
            raise ValueError(
                "each right-side string variable must occur exactly once"
            )
        if len(left_atoms) != len(set(left_atoms)):
            raise ValueError("a linear rule cannot copy a string variable")
        if set(left_atoms) != set(right_vars):
            raise ValueError(
                "the left and right sides must use the same string variables"
            )

    @property
    def left_side(self) -> MCFGRuleElement:
        """The left side of the rule."""
        return self._left_side

    @property
    def right_side(self) -> tuple[MCFGRuleElement, ...]:
        """The right side elements."""
        return self._right_side

    @property
    def is_terminal(self) -> bool:
        """Whether this rule has a constant terminal yield."""
        return len(self._right_side) == 0

    @property
    def unique_variables(self) -> set[str]:
        """The set of unique variable names across both sides."""
        return {el.variable for el in [self._left_side] + list(self._right_side)}

    def instantiate_left_side(
        self, *right_side: MCFGRuleElementInstance
    ) -> MCFGRuleElementInstance:
        """Instantiate the left side of the rule given an instantiated right side.

        Parameters
        ----------
        right_side : MCFGRuleElementInstance
            The instantiated right side elements.

        Returns
        -------
        MCFGRuleElementInstance
            The instantiated left side element.

        Raises
        ------
        ValueError
            If spans are not adjacent as required by the rule.
        """

        if self.is_terminal:
            raise ValueError(
                "a terminal rule is instantiated directly from an input token"
            )

        integer_components: list[tuple[int, ...]] = []
        for component in self._left_side.string_variables:
            if not all(isinstance(variable, int) for variable in component):
                raise TypeError("nonterminal rules require integer string variables")
            integer_components.append(
                tuple(variable for variable in component if isinstance(variable, int))
            )

        new_spans = []
        span_map = self._build_span_map(right_side)

        for vs in integer_components:
            for i in range(1, len(vs)):
                end_prev = span_map[vs[i - 1]][1]
                begin_curr = span_map[vs[i]][0]

                if end_prev != begin_curr:
                    raise ValueError(
                        f"Spans {span_map[vs[i - 1]]} and {span_map[vs[i]]} "
                        f"must be adjacent according to {self} but they "
                        "are not."
                    )

            begin_span = span_map[vs[0]][0]
            end_span = span_map[vs[-1]][1]

            new_spans.append((begin_span, end_span))

        return MCFGRuleElementInstance(self._left_side.variable, *new_spans)

    def _build_span_map(
        self, right_side: tuple[MCFGRuleElementInstance, ...]
    ) -> SpanMap:
        """Construct a mapping from string variables to string spans.

        Parameters
        ----------
        right_side : tuple[MCFGRuleElementInstance, ...]
            The instantiated right side elements.

        Returns
        -------
        SpanMap
            Mapping from string variable indices to span tuples.

        Raises
        ------
        ValueError
            If the instantiated right side does not align with the rule.
        """

        if self._right_side_aligns(right_side):
            span_map: SpanMap = {}
            for element, instance in zip(self._right_side, right_side):
                for component, span in zip(
                    element.string_variables,
                    instance.string_spans,
                ):
                    variable = component[0]
                    if not isinstance(variable, int):
                        raise TypeError(
                            "a span variable must be represented by an integer"
                        )
                    span_map[variable] = span
            return span_map
        else:
            raise ValueError(
                f"Instantiated right side {right_side} do not "
                f"align with rule's right side {self._right_side}"
            )

    def _right_side_aligns(
        self, right_side: tuple[MCFGRuleElementInstance, ...]
    ) -> bool:
        """Check whether the instantiated right side aligns with the rule.

        Parameters
        ----------
        right_side : tuple[MCFGRuleElementInstance, ...]
            The instantiated right side elements.

        Returns
        -------
        bool
            Whether the right side aligns.
        """

        if len(right_side) == len(self._right_side):
            vars_match = all(
                elem.variable == eleminst.variable
                for elem, eleminst in zip(self._right_side, right_side)
            )
            strvars_match = all(
                len(elem.string_variables) == len(eleminst.string_spans)
                for elem, eleminst in zip(self._right_side, right_side)
            )

            return vars_match and strvars_match
        else:
            return False

    @classmethod
    def from_string(cls, rule_string: str) -> MCFGRule:
        """Parse an MCFG rule from a string representation.

        Parameters
        ----------
        rule_string : str
            The rule string to parse, e.g. ``'A(uv) -> B(u) C(v)'``.

        Returns
        -------
        MCFGRule
            The parsed rule.
        """
        elem_strs = re.findall(r"(\w+)\(((?:\w+,? ?)+?)\)", rule_string)

        elem_tuples = [
            (var, [v.strip() for v in svs.split(",")]) for var, svs in elem_strs
        ]

        if len(elem_tuples) == 1:
            return cls(
                MCFGRuleElement(elem_tuples[0][0], tuple(w for w in elem_tuples[0][1]))
            )

        else:
            strvars = [v for _, sv in elem_tuples[1:] for v in sv]

            if len(strvars) != len(set(strvars)):
                raise ValueError("variables duplicated on right side of " + rule_string)

            elem_left = MCFGRuleElement(
                elem_tuples[0][0],
                *[
                    tuple(
                        [
                            strvars.index(v)
                            for v in re.findall(
                                "|".join(
                                    re.escape(variable)
                                    for variable in sorted(
                                        strvars, key=len, reverse=True
                                    )
                                ),
                                vs,
                            )
                        ]
                    )
                    for vs in elem_tuples[0][1]
                ],
            )

            elems_right = [
                MCFGRuleElement(var, *[(strvars.index(sv),) for sv in svs])
                for var, svs in elem_tuples[1:]
            ]

            return cls(elem_left, *elems_right)

    def string_yield(self) -> str:
        """Return the constant yielded by a terminal rule.

        Raises
        ------
        ValueError
            If this is not a terminal rule.
        """
        if not self.is_terminal:
            raise ValueError("string_yield is only defined for terminal rules")
        string_yield = self._left_side.string_variables[0][0]
        if not isinstance(string_yield, str):
            raise TypeError("a terminal yield must be a string")
        return string_yield

Now we need to load a grammar. The provided class method reads rules from strings.

rule = MCFGRule.from_string("A(w1u, x1v) -> B(w1, x1) C(u, v)")

rule

terminal_rule = MCFGRule.from_string("Dwh(which)")
assert terminal_rule.is_terminal
assert terminal_rule.string_yield() == "which"

try:
    MCFGRule.from_string("A(uu) -> B(u)")
except ValueError:
    pass
else:
    raise AssertionError("a linear MCFG rule cannot copy a string variable")

try:
    MCFGRule(
        MCFGRuleElement("A", (0,)),
        MCFGRuleElement("B", (0,), (0,)),
    )
except ValueError:
    pass
else:
    raise AssertionError(
        "a right-side string variable cannot be reused within one element"
    )

try:
    MCFGRule(
        MCFGRuleElement("A", ()),
        MCFGRuleElement("B", (0,)),
    )
except ValueError:
    pass
else:
    raise AssertionError(
        "the starter representation excludes empty components"
    )

overlapping_names = MCFGRule.from_string("A(u1u) -> B(u) C(u1)")
assert overlapping_names.left_side.string_variables == ((1, 0),)

Before implementing an inference rule, work through MCFGRule.instantiate_left_side. What must this method verify? It receives instantiated right-side elements, checks their nonterminal names and fan-outs, verifies every required adjacency, and constructs the left-side spans. The example below shows each record explicitly.

rule.instantiate_left_side(
    MCFGRuleElementInstance("B", (1, 2), (5, 7)),
    MCFGRuleElementInstance("C", (2, 4), (7, 8)),
)

With this method supplied, your implementation can focus on deductive parsing and agenda control. The parser must still decide which antecedent items to combine, call the rule on them, avoid duplicate chart work, record backpointers, and identify the goal item.